Files
Codewalkers/apps/server/test/cassette/harness.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

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;
}