Files
callstack__agent-device/src/__tests__/cli-capture.ts
Michał Pierzchała f4882bc706 feat: support live replay test reporters (#959)
* feat: support live replay test reporters

* refactor: simplify replay progress readers

* fix: preserve verbose replay reporter progress

* feat: expose semantic replay reporter hooks

* refactor: trim replay reporter context

* refactor: trim reporter progress internals

* refactor: move replay test reporting under replay

* refactor: make live replay reporter hooks synchronous and simplify dispatch

Live reporter hooks (onSuiteStart/onTestStart/onTestStep/onTestResult)
were typed as `void | Promise<void>` but fired from the synchronous daemon
progress stream reader without being awaited, so a stateful async reporter
could receive onSuiteEnd before its live work settled. Type them as `void`
to make the contract honest; onSuiteEnd stays awaited for async flushing.

A returned promise from a misbehaving custom JS reporter is still caught so
it cannot crash the CLI with an unhandled rejection, but it is documented as
unsupported and not awaited.

Collapse the four near-identical per-event hook dispatch branches into a
single table-driven path, and document the synchronous-hook and
exit-code-escalation contracts. Add a regression test covering a throwing
live hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XXHAYxWpvSzqc6CtneYL8J

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-30 21:10:22 +02:00

123 lines
3.6 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runCli } from '../cli.ts';
import type {
DaemonRequest,
DaemonResponse,
sendToDaemon,
} from '../daemon/client/daemon-client.ts';
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
class ExitSignal extends Error {
public readonly code: number;
constructor(code: number) {
super(`EXIT_${code}`);
this.code = code;
}
}
export type CapturedDaemonRequest = Omit<DaemonRequest, 'token'>;
type DaemonTransportOptions = Parameters<typeof sendToDaemon>[1];
export type CapturedCliRun = {
code: number | null;
stdout: string;
stderr: string;
calls: CapturedDaemonRequest[];
};
export type CliCaptureOptions = {
cwd?: string;
env?: Record<string, string | undefined>;
stateDirPrefix?: string;
passthroughBufferWrites?: boolean;
sendToDaemon?: (
req: CapturedDaemonRequest,
options?: DaemonTransportOptions,
) => Promise<DaemonResponse>;
defaultResponse?: DaemonResponse;
};
type CliCaptureResponder = (
req: CapturedDaemonRequest,
options?: DaemonTransportOptions,
) => Promise<DaemonResponse>;
export async function runCliCapture(
argv: string[],
responderOrOptions: CliCaptureResponder | CliCaptureOptions = {},
extraOptions: CliCaptureOptions = {},
): Promise<CapturedCliRun> {
const options =
typeof responderOrOptions === 'function'
? { ...extraOptions, sendToDaemon: responderOrOptions }
: { ...extraOptions, ...(responderOrOptions ?? {}) };
let stdout = '';
let stderr = '';
let code: number | null = null;
const calls: CapturedDaemonRequest[] = [];
const stateDir = options.stateDirPrefix
? fs.mkdtempSync(path.join(os.tmpdir(), options.stateDirPrefix))
: undefined;
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const originalCwd = process.cwd();
const restoreEnv = installIsolatedCliTestEnv({
...(options.env ?? {}),
...(stateDir ? { AGENT_DEVICE_STATE_DIR: stateDir } : {}),
});
if (options.cwd) {
process.chdir(options.cwd);
}
(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
}) as typeof process.exit;
(process.stdout as any).write = ((chunk: unknown, ...args: unknown[]) => {
if (options.passthroughBufferWrites && Buffer.isBuffer(chunk)) {
return originalStdoutWrite(chunk, ...(args as [any]));
}
stdout += String(chunk);
return true;
}) as typeof process.stdout.write;
(process.stderr as any).write = ((chunk: unknown, ...args: unknown[]) => {
if (options.passthroughBufferWrites && Buffer.isBuffer(chunk)) {
return originalStderrWrite(chunk, ...(args as [any]));
}
stderr += String(chunk);
return true;
}) as typeof process.stderr.write;
const sendToDaemon = async (
req: CapturedDaemonRequest,
daemonOptions?: DaemonTransportOptions,
): Promise<DaemonResponse> => {
calls.push(req);
if (options.sendToDaemon) {
return await options.sendToDaemon(req, daemonOptions);
}
return options.defaultResponse ?? { ok: true, data: {} };
};
try {
await runCli(argv, { sendToDaemon });
} catch (error) {
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
restoreEnv();
if (stateDir) fs.rmSync(stateDir, { recursive: true, force: true });
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
process.chdir(originalCwd);
}
return { code, stdout, stderr, calls };
}