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
266 lines
9.3 KiB
TypeScript
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);
|
|
});
|
|
});
|