mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
f4882bc706
* 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>
123 lines
3.6 KiB
TypeScript
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 };
|
|
}
|