Files
Codewalkers/apps/server/test/cassette/cassette.test.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

266 lines
9.3 KiB
TypeScript

/**
* Cassette System Unit Tests
*
* Verifies normalizer, key generation, and store in isolation.
* These run without any real processes or API calls.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { normalizePrompt, stripPromptFromArgs } from './normalizer.js';
import { hashWorktreeFiles, buildCassetteKey } from './key.js';
import { CassetteStore } from './store.js';
import type { CassetteEntry, CassetteKey } from './types.js';
// ---------------------------------------------------------------------------
// Normalizer
// ---------------------------------------------------------------------------
describe('normalizePrompt', () => {
it('strips UUIDs', () => {
const prompt = 'Agent 550e8400-e29b-41d4-a716-446655440000 is running task abc123ef-0000-0000-0000-000000000000';
const result = normalizePrompt(prompt, '');
expect(result).not.toContain('550e8400');
expect(result).not.toContain('abc123ef');
expect(result).toContain('__UUID__');
});
it('strips workspace root path', () => {
const workspaceRoot = '/tmp/cw-test-abc123';
const prompt = `Working directory: ${workspaceRoot}/agent-workdirs/my-agent`;
const result = normalizePrompt(prompt, workspaceRoot);
expect(result).not.toContain(workspaceRoot);
expect(result).toContain('__WORKSPACE__');
});
it('strips ISO timestamps', () => {
const prompt = 'Started at 2026-03-01T14:30:00Z, last seen 2026-03-01T14:35:22.456Z';
const result = normalizePrompt(prompt, '');
expect(result).not.toContain('2026-03-01');
expect(result).toContain('__TIMESTAMP__');
});
it('strips session numbers', () => {
const prompt = 'Resuming session 3 with agent session-42';
const result = normalizePrompt(prompt, '');
expect(result).toContain('session__N__');
expect(result).not.toContain('session 3');
expect(result).not.toContain('session-42');
});
it('leaves static content unchanged', () => {
const prompt = 'You are a Worker agent. Execute the assigned coding task.';
const result = normalizePrompt(prompt, '/tmp/ws');
expect(result).toBe(prompt);
});
it('strips nanoid strings (21-char alphanumeric)', () => {
const nanoid = 'V1StGXR8_Z5jdHi6B-myT';
const prompt = `Agent worktree: /tmp/cw-preview-${nanoid}/app`;
const result = normalizePrompt(prompt, '');
expect(result).not.toContain(nanoid);
expect(result).toContain('__ID__');
});
it('strips workspace root before UUID replacement to avoid double-normalizing', () => {
const workspaceRoot = '/tmp/cw-test-abc123';
const uuid = '550e8400-e29b-41d4-a716-446655440000';
const prompt = `Dir: ${workspaceRoot}/agents/${uuid}`;
const result = normalizePrompt(prompt, workspaceRoot);
expect(result).toBe('Dir: __WORKSPACE__/agents/__UUID__');
});
});
describe('stripPromptFromArgs', () => {
it('strips -p <prompt> style (Claude native)', () => {
const prompt = 'Do the task.';
const args = ['--dangerously-skip-permissions', '--verbose', '-p', prompt, '--output-format', 'stream-json'];
const result = stripPromptFromArgs(args, prompt);
expect(result).toEqual(['--dangerously-skip-permissions', '--verbose', '--output-format', 'stream-json']);
});
it('strips --prompt <prompt> style', () => {
const prompt = 'Do the task.';
const args = ['--flag', '--prompt', prompt, '--json'];
const result = stripPromptFromArgs(args, prompt);
expect(result).toEqual(['--flag', '--json']);
});
it('strips bare positional prompt', () => {
const prompt = 'Do the task.';
const args = ['--full-auto', prompt];
const result = stripPromptFromArgs(args, prompt);
expect(result).toEqual(['--full-auto']);
});
it('returns unchanged args when prompt is empty', () => {
const args = ['--flag', '--value'];
expect(stripPromptFromArgs(args, '')).toEqual(args);
});
});
// ---------------------------------------------------------------------------
// Key generation
// ---------------------------------------------------------------------------
describe('buildCassetteKey', () => {
const baseKey: CassetteKey = {
normalizedPrompt: 'You are a Worker agent.',
providerName: 'claude',
modelArgs: ['--dangerously-skip-permissions', '--verbose', '--output-format', 'stream-json'],
worktreeHash: 'empty',
};
it('produces a 32-char hex string', () => {
const key = buildCassetteKey(baseKey);
expect(key).toMatch(/^[0-9a-f]{32}$/);
});
it('is deterministic for the same key', () => {
expect(buildCassetteKey(baseKey)).toBe(buildCassetteKey(baseKey));
});
it('differs when normalizedPrompt changes', () => {
const key2 = { ...baseKey, normalizedPrompt: 'You are a Discuss agent.' };
expect(buildCassetteKey(baseKey)).not.toBe(buildCassetteKey(key2));
});
it('differs when providerName changes', () => {
const key2 = { ...baseKey, providerName: 'codex' };
expect(buildCassetteKey(baseKey)).not.toBe(buildCassetteKey(key2));
});
it('differs when worktreeHash changes', () => {
const key2 = { ...baseKey, worktreeHash: 'abcdef1234567890' };
expect(buildCassetteKey(baseKey)).not.toBe(buildCassetteKey(key2));
});
it('is stable regardless of modelArgs insertion order', () => {
const key1 = { ...baseKey, modelArgs: ['--verbose', '--dangerously-skip-permissions'] };
const key2 = { ...baseKey, modelArgs: ['--dangerously-skip-permissions', '--verbose'] };
expect(buildCassetteKey(key1)).toBe(buildCassetteKey(key2));
});
});
describe('hashWorktreeFiles', () => {
it('returns "empty" for a non-existent directory', () => {
expect(hashWorktreeFiles('/does/not/exist')).toBe('empty');
});
it('returns "empty" for a directory with only hidden files', () => {
const dir = mkdtempSync(join(tmpdir(), 'cw-hash-test-'));
try {
// Only hidden entries present
const { mkdirSync } = require('node:fs');
mkdirSync(join(dir, '.git'));
expect(hashWorktreeFiles(dir)).toBe('empty');
} finally {
rmSync(dir, { recursive: true });
}
});
it('produces a 16-char hex string for a directory with files', () => {
const dir = mkdtempSync(join(tmpdir(), 'cw-hash-test-'));
try {
const { writeFileSync } = require('node:fs');
writeFileSync(join(dir, 'index.ts'), 'export const x = 1;');
const hash = hashWorktreeFiles(dir);
expect(hash).toMatch(/^[0-9a-f]{16}$/);
} finally {
rmSync(dir, { recursive: true });
}
});
it('changes when file content changes', () => {
const dir = mkdtempSync(join(tmpdir(), 'cw-hash-test-'));
try {
const { writeFileSync } = require('node:fs');
writeFileSync(join(dir, 'index.ts'), 'export const x = 1;');
const hash1 = hashWorktreeFiles(dir);
writeFileSync(join(dir, 'index.ts'), 'export const x = 2;');
const hash2 = hashWorktreeFiles(dir);
expect(hash1).not.toBe(hash2);
} finally {
rmSync(dir, { recursive: true });
}
});
});
// ---------------------------------------------------------------------------
// CassetteStore
// ---------------------------------------------------------------------------
describe('CassetteStore', () => {
let dir: string;
let store: CassetteStore;
const key: CassetteKey = {
normalizedPrompt: 'Test prompt',
providerName: 'claude',
modelArgs: ['--verbose'],
worktreeHash: 'empty',
};
const entry: CassetteEntry = {
version: 1,
key,
recording: {
jsonlLines: ['{"type":"system","session_id":"test-session"}', '{"type":"result","subtype":"success"}'],
signalJson: { status: 'done', message: 'Task completed' },
exitCode: 0,
recordedAt: '2026-03-01T00:00:00.000Z',
},
};
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cw-store-test-'));
store = new CassetteStore(dir);
});
it('returns null for unknown key', () => {
expect(store.find(key)).toBeNull();
});
it('round-trips a cassette entry', () => {
store.save(key, entry);
const loaded = store.find(key);
expect(loaded).not.toBeNull();
expect(loaded?.recording.signalJson).toEqual({ status: 'done', message: 'Task completed' });
expect(loaded?.recording.jsonlLines).toHaveLength(2);
});
it('overwrites an existing cassette', () => {
store.save(key, entry);
const updated: CassetteEntry = {
...entry,
recording: { ...entry.recording, jsonlLines: ['new line'], recordedAt: '2026-03-02T00:00:00.000Z' },
};
store.save(key, updated);
const loaded = store.find(key);
expect(loaded?.recording.jsonlLines).toEqual(['new line']);
});
it('uses same file for same key', () => {
store.save(key, entry);
const { readdirSync } = require('node:fs');
const files = readdirSync(dir).filter((f: string) => f.endsWith('.json'));
expect(files).toHaveLength(1);
store.save(key, entry); // overwrite
const files2 = readdirSync(dir).filter((f: string) => f.endsWith('.json'));
expect(files2).toHaveLength(1);
});
it('uses different files for different keys', () => {
const key2: CassetteKey = { ...key, providerName: 'codex' };
store.save(key, entry);
store.save(key2, { ...entry, key: key2 });
const { readdirSync } = require('node:fs');
const files = readdirSync(dir).filter((f: string) => f.endsWith('.json'));
expect(files).toHaveLength(2);
});
});