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
201 lines
7.2 KiB
TypeScript
201 lines
7.2 KiB
TypeScript
/**
|
|
* Cassette Test Harness
|
|
*
|
|
* Wraps RealProviderHarness with the CassetteProcessManager so tests run
|
|
* against recorded cassettes instead of real AI APIs.
|
|
*
|
|
* Usage:
|
|
*
|
|
* let harness: RealProviderHarness;
|
|
*
|
|
* beforeAll(async () => {
|
|
* harness = await createCassetteHarness({ provider: 'claude' });
|
|
* });
|
|
*
|
|
* afterAll(() => harness.cleanup());
|
|
*
|
|
* it('completes a task', async () => {
|
|
* const agent = await harness.agentManager.spawn({ prompt: MINIMAL_PROMPTS.done, ... });
|
|
* const result = await harness.waitForAgentCompletion(agent.id);
|
|
* expect(result?.success).toBe(true);
|
|
* });
|
|
*
|
|
* Mode control via env vars:
|
|
* (default) → replay mode: cassette must exist, throws if missing
|
|
* CW_CASSETTE_RECORD=1 → auto mode: replay if exists, record if missing
|
|
* CW_CASSETTE_FORCE_RECORD=1→ record mode: always run real agent, overwrite cassette
|
|
*/
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { execSync } from 'node:child_process';
|
|
import { join } from 'node:path';
|
|
import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js';
|
|
import {
|
|
DrizzleAgentRepository,
|
|
DrizzleProjectRepository,
|
|
DrizzleAccountRepository,
|
|
DrizzleInitiativeRepository,
|
|
} from '../../db/repositories/drizzle/index.js';
|
|
import { MultiProviderAgentManager } from '../../agent/manager.js';
|
|
import { CapturingEventBus, sleep, type RealProviderHarness } from '../integration/real-providers/harness.js';
|
|
import { CassetteStore } from './store.js';
|
|
import { CassetteProcessManager, type CassetteMode } from './process-manager.js';
|
|
|
|
export interface CassetteHarnessOptions {
|
|
/** Which provider the agent runs as (default: 'claude'). */
|
|
provider?: 'claude' | 'codex';
|
|
/**
|
|
* Directory where cassette JSON files are stored and read from.
|
|
* Defaults to CW_CASSETTE_DIR env var, then src/test/cassettes/.
|
|
*/
|
|
cassetteDir?: string;
|
|
/**
|
|
* Override cassette mode. Normally derived from env vars:
|
|
* - CW_CASSETTE_FORCE_RECORD=1 → 'record'
|
|
* - CW_CASSETTE_RECORD=1 → 'auto'
|
|
* - (default) → 'replay'
|
|
*/
|
|
mode?: CassetteMode;
|
|
}
|
|
|
|
const DEFAULT_CASSETTE_DIR = new URL('../cassettes', import.meta.url).pathname;
|
|
|
|
/**
|
|
* Resolve cassette mode from env vars (highest priority) or options.
|
|
*/
|
|
function resolveCassetteMode(options: CassetteHarnessOptions): CassetteMode {
|
|
if (process.env.CW_CASSETTE_FORCE_RECORD === '1') return 'record';
|
|
if (process.env.CW_CASSETTE_RECORD === '1') return 'auto';
|
|
return options.mode ?? 'replay';
|
|
}
|
|
|
|
/**
|
|
* Create a test harness backed by the cassette system.
|
|
*
|
|
* The harness exposes the same interface as RealProviderHarness so tests
|
|
* written for real providers work unchanged with cassettes.
|
|
*
|
|
* Replay is much faster than real API calls (typically < 500ms) and
|
|
* exercises the full pipeline: ProcessManager → FileTailer → OutputHandler
|
|
* → SignalManager → event emission.
|
|
*/
|
|
export async function createCassetteHarness(options: CassetteHarnessOptions = {}): Promise<RealProviderHarness> {
|
|
const cassetteDir = options.cassetteDir ?? process.env.CW_CASSETTE_DIR ?? DEFAULT_CASSETTE_DIR;
|
|
const cassetteMode = resolveCassetteMode(options);
|
|
|
|
// Create a temporary git workspace (required for worktree operations).
|
|
const workspaceRoot = await mkdtemp(join(tmpdir(), 'cw-cassette-'));
|
|
execSync('git init', { cwd: workspaceRoot, stdio: 'ignore' });
|
|
execSync('git config user.email "test@test.com"', { cwd: workspaceRoot, stdio: 'ignore' });
|
|
execSync('git config user.name "Test"', { cwd: workspaceRoot, stdio: 'ignore' });
|
|
execSync('touch .gitkeep && git add .gitkeep && git commit -m "init"', { cwd: workspaceRoot, stdio: 'ignore' });
|
|
|
|
const db = createTestDatabase();
|
|
const agentRepository = new DrizzleAgentRepository(db);
|
|
const projectRepository = new DrizzleProjectRepository(db);
|
|
const accountRepository = new DrizzleAccountRepository(db);
|
|
const initiativeRepository = new DrizzleInitiativeRepository(db);
|
|
const eventBus = new CapturingEventBus();
|
|
|
|
const store = new CassetteStore(cassetteDir);
|
|
const cassetteProcessManager = new CassetteProcessManager(
|
|
workspaceRoot,
|
|
projectRepository,
|
|
store,
|
|
cassetteMode,
|
|
);
|
|
|
|
const agentManager = new MultiProviderAgentManager(
|
|
agentRepository,
|
|
workspaceRoot,
|
|
projectRepository,
|
|
accountRepository,
|
|
eventBus,
|
|
undefined, // credentialManager
|
|
undefined, // changeSetRepository
|
|
undefined, // phaseRepository
|
|
undefined, // taskRepository
|
|
undefined, // pageRepository
|
|
undefined, // logChunkRepository
|
|
false, // debug
|
|
cassetteProcessManager,
|
|
);
|
|
|
|
const harness: RealProviderHarness = {
|
|
db,
|
|
eventBus,
|
|
agentManager,
|
|
workspaceRoot,
|
|
agentRepository,
|
|
projectRepository,
|
|
accountRepository,
|
|
initiativeRepository,
|
|
|
|
// Cassette replays are fast — use a short poll interval and default timeout.
|
|
async waitForAgentCompletion(agentId, timeoutMs = 30_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const agent = await agentRepository.findById(agentId);
|
|
if (!agent) return null;
|
|
if (agent.status === 'idle' || agent.status === 'stopped') {
|
|
return agentManager.getResult(agentId);
|
|
}
|
|
if (agent.status === 'crashed') {
|
|
return agentManager.getResult(agentId);
|
|
}
|
|
if (agent.status === 'waiting_for_input') return null;
|
|
await sleep(100);
|
|
}
|
|
throw new Error(`[cassette] Timeout waiting for agent ${agentId} to complete after ${timeoutMs}ms`);
|
|
},
|
|
|
|
async waitForAgentWaiting(agentId, timeoutMs = 30_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const agent = await agentRepository.findById(agentId);
|
|
if (!agent) return null;
|
|
if (agent.status === 'waiting_for_input') return agentManager.getPendingQuestions(agentId);
|
|
if (['idle', 'stopped', 'crashed'].includes(agent.status)) return null;
|
|
await sleep(100);
|
|
}
|
|
throw new Error(`[cassette] Timeout waiting for agent ${agentId} to enter waiting state after ${timeoutMs}ms`);
|
|
},
|
|
|
|
async waitForAgentStatus(agentId, status, timeoutMs = 30_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const agent = await agentRepository.findById(agentId);
|
|
if (!agent) throw new Error(`Agent ${agentId} not found`);
|
|
if (agent.status === status) return;
|
|
await sleep(100);
|
|
}
|
|
throw new Error(`[cassette] Timeout waiting for agent ${agentId} to reach status '${status}' after ${timeoutMs}ms`);
|
|
},
|
|
|
|
getEventsByType(type) {
|
|
return eventBus.getEventsByType(type);
|
|
},
|
|
|
|
clearEvents() {
|
|
eventBus.clearEvents();
|
|
},
|
|
|
|
async killAllAgents() {
|
|
const agents = await agentRepository.findAll();
|
|
for (const agent of agents) {
|
|
if (agent.status === 'running') {
|
|
try { await agentManager.stop(agent.id); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
},
|
|
|
|
async cleanup() {
|
|
await harness.killAllAgents();
|
|
try { await rm(workspaceRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
},
|
|
};
|
|
|
|
return harness;
|
|
}
|