mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Run 37158847998 recorded codex-review-findings as passed again: the agent wrote 'I couldn't run the review because the execution sandbox failed (bwrap: Failed to make / slave)', the failure never reached stderr or a command event, and no phrase rule matched. validateCodexReview now also requires at least one command_execution event that completed with exit 0. Fixtures gain that event; the CI output is a new negative control.
459 lines
22 KiB
TypeScript
459 lines
22 KiB
TypeScript
/** Free behavioral tests: every Codex launch goes through our temporary shim.
|
|
* Uses '/bin/bash' to launch the shim (excluded from Windows curation).
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { CodexHarnessError, runCodexSkill } from './helpers/codex-session-runner';
|
|
import { runRecordedCodexEval, validateCodexDiscovery } from './helpers/codex-eval';
|
|
import type { EvalTestEntry } from './helpers/eval-store';
|
|
|
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
|
|
function alive(pid: number): boolean {
|
|
// A reparented, killed grandchild can briefly be a zombie until init reaps
|
|
// it. It is no longer executing or holding any pipe open.
|
|
const status = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8', timeout: 2_000 });
|
|
return status.status === 0 && !status.stdout.trim().startsWith('Z');
|
|
}
|
|
|
|
async function withFakeCodex(
|
|
body: string,
|
|
check: (fixture: { dir: string; skillDir: string; shimDir: string; pids: () => number[]; tempHome: () => string }) => Promise<void>,
|
|
) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-lifecycle-fixture-'));
|
|
const shimDir = path.join(dir, 'shims');
|
|
const skillDir = path.join(dir, 'skill');
|
|
const operatorConfig = path.join(dir, 'operator-codex');
|
|
const pidsFile = path.join(dir, 'pids.json');
|
|
const homeFile = path.join(dir, 'child-home.txt');
|
|
const script = path.join(dir, 'fake-codex.ts');
|
|
fs.mkdirSync(shimDir);
|
|
fs.mkdirSync(skillDir);
|
|
fs.mkdirSync(operatorConfig);
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: fixture\ndescription: local test\n---\nReview the fixture.\n');
|
|
fs.writeFileSync(script, `
|
|
import * as fs from 'node:fs';
|
|
import { spawn } from 'node:child_process';
|
|
const pidsFile = ${JSON.stringify(pidsFile)};
|
|
fs.writeFileSync(pidsFile, JSON.stringify([process.pid]));
|
|
fs.writeFileSync(${JSON.stringify(homeFile)}, process.env.HOME!);
|
|
${body}
|
|
`);
|
|
fs.writeFileSync(path.join(shimDir, 'codex'), `#!/bin/bash\nexec ${shellQuote(process.execPath)} ${shellQuote(script)} "$@"\n`, { mode: 0o755 });
|
|
const original = { PATH: process.env.PATH, CODEX_HOME: process.env.CODEX_HOME, EVALS_HERMETIC: process.env.EVALS_HERMETIC };
|
|
const pids = () => fs.existsSync(pidsFile) ? JSON.parse(fs.readFileSync(pidsFile, 'utf8')) as number[] : [];
|
|
process.env.PATH = `${shimDir}${path.delimiter}${original.PATH}`;
|
|
process.env.CODEX_HOME = operatorConfig; // Never copy real operator credentials.
|
|
process.env.EVALS_HERMETIC = '1';
|
|
try {
|
|
await check({ dir, skillDir, shimDir, pids, tempHome: () => fs.readFileSync(homeFile, 'utf8') });
|
|
} finally {
|
|
for (const pid of pids()) {
|
|
// An intentionally escaped child tests pipe teardown. Only the fixture
|
|
// owner knows its new group ID, so it is explicitly reaped here.
|
|
try { process.kill(-pid, 'SIGKILL'); } catch { /* no such group */ }
|
|
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
|
}
|
|
for (const [key, value] of Object.entries(original)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function childHoldingPipes(detached: boolean, exitCode?: number): string {
|
|
return `
|
|
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
|
detached: ${detached}, stdio: ['ignore', 'ignore', 'inherit'],
|
|
});
|
|
fs.writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid]));
|
|
child.unref();
|
|
process.stderr.write('invalid fixture metadata before the pipe stalled\\n');
|
|
${exitCode === undefined ? 'setInterval(() => {}, 1000);' : `process.exit(${exitCode});`}
|
|
`;
|
|
}
|
|
|
|
describe('Codex subprocess lifecycle without API calls', () => {
|
|
test('a missing full skill fixture records one harness error without launching Codex', async () => {
|
|
await withFakeCodex(`process.stdout.write(JSON.stringify({ type: 'item.completed', item: {
|
|
type: 'agent_message', text: 'No gstack review skill is installed here.',
|
|
} }));`, async ({ skillDir, pids }) => {
|
|
const source = path.join(skillDir, 'SKILL.md');
|
|
fs.unlinkSync(source);
|
|
const entries: EvalTestEntry[] = [];
|
|
let failure: unknown;
|
|
try {
|
|
await runRecordedCodexEval({
|
|
name: 'missing-full-fixture', suite: 'codex-lifecycle', budgetMs: 2_000,
|
|
run: (signal) => runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000, signal }),
|
|
validate: validateCodexDiscovery,
|
|
record: (entry) => entries.push(entry),
|
|
});
|
|
} catch (error) { failure = error; }
|
|
expect(failure).toBeInstanceOf(Error);
|
|
expect((failure as Error).message).toContain('ENOENT');
|
|
expect((failure as Error).message).toContain(source);
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]).toMatchObject({ passed: false, exit_reason: 'harness_error', error: (failure as Error).message });
|
|
expect(pids()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test('captures full JSONL/stderr, including split UTF-8, and removes the temporary home', async () => {
|
|
await withFakeCodex(`
|
|
const line = Buffer.from(JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'gstack review: café' } }) + '\\n');
|
|
for (const byte of line) fs.writeSync(1, Buffer.from([byte]));
|
|
for (const byte of Buffer.from('fixture warning: café\\n')) fs.writeSync(2, Buffer.from([byte]));
|
|
process.stdout.write(JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 7, output_tokens: 3 } }));
|
|
`, async ({ skillDir, tempHome, pids }) => {
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output).toBe('gstack review: café');
|
|
expect(result.stderr).toBe('fixture warning: café\n');
|
|
expect(result.tokens).toBe(10);
|
|
expect(result.rawLines).toHaveLength(2);
|
|
expect(fs.existsSync(tempHome())).toBe(false);
|
|
expect(pids().every((pid) => !alive(pid))).toBe(true);
|
|
});
|
|
});
|
|
|
|
test('CODEX_HOME is never under the child temp dir, so Codex can create its PATH aliases', async () => {
|
|
const record = path.join(os.tmpdir(), `codex-lifecycle-child-env-${process.pid}.json`);
|
|
try {
|
|
await withFakeCodex(`
|
|
fs.writeFileSync(${JSON.stringify(record)}, JSON.stringify({ tmp: process.env.TMPDIR, codexHome: process.env.CODEX_HOME }));
|
|
`, async ({ skillDir }) => {
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 });
|
|
expect(result.exitCode).toBe(0);
|
|
const child = JSON.parse(fs.readFileSync(record, 'utf8')) as { tmp: string; codexHome: string };
|
|
expect(child.tmp).toBeTruthy();
|
|
expect(path.relative(child.tmp, child.codexHome).startsWith('..')).toBe(true);
|
|
});
|
|
} finally {
|
|
fs.rmSync(record, { force: true });
|
|
}
|
|
});
|
|
|
|
for (const exitCode of [2, 137]) {
|
|
test(`retains process exit ${exitCode}`, async () => {
|
|
await withFakeCodex(`process.exit(${exitCode});`, async ({ skillDir }) => {
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 });
|
|
expect(result.exitCode).toBe(exitCode);
|
|
});
|
|
});
|
|
}
|
|
|
|
for (const exitCode of [0, 2]) {
|
|
test(`exit ${exitCode} reaps descendants that have already closed inherited pipes`, async () => {
|
|
await withFakeCodex(`
|
|
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });
|
|
fs.writeFileSync(pidsFile, JSON.stringify([process.pid, child.pid]));
|
|
child.unref();
|
|
process.exit(${exitCode});
|
|
`, async ({ skillDir, pids, tempHome }) => {
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 });
|
|
expect(result.exitCode).toBe(exitCode);
|
|
expect(pids()).toHaveLength(2);
|
|
const cleanupDeadline = Date.now() + 1_000;
|
|
let allExited = pids().every((pid) => !alive(pid));
|
|
while (!allExited && Date.now() < cleanupDeadline) {
|
|
await new Promise((resolve) => setTimeout(resolve, Math.min(25, cleanupDeadline - Date.now())));
|
|
allExited = pids().every((pid) => !alive(pid));
|
|
}
|
|
expect(allExited).toBe(true);
|
|
expect(fs.existsSync(tempHome())).toBe(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
test('a real SIGKILL is recorded as exit 137, not a timeout', async () => {
|
|
await withFakeCodex("process.kill(process.pid, 'SIGKILL');", async ({ skillDir }) => {
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 });
|
|
expect(result.exitCode).toBe(137);
|
|
});
|
|
});
|
|
|
|
test('timeout kills the process group and drains both streams within the allowance', async () => {
|
|
await withFakeCodex(childHoldingPipes(false), async ({ skillDir, pids, tempHome }) => {
|
|
const started = Date.now();
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 750 });
|
|
expect(result.exitCode).toBe(124);
|
|
expect(Date.now() - started).toBeLessThan(5_000);
|
|
expect(pids()).toHaveLength(2);
|
|
expect(pids().every((pid) => !alive(pid))).toBe(true);
|
|
expect(fs.existsSync(tempHome())).toBe(false);
|
|
});
|
|
}, 10_000);
|
|
|
|
test('abort kills a running group and removes its temporary home', async () => {
|
|
await withFakeCodex(childHoldingPipes(false), async ({ skillDir, pids, tempHome }) => {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 750);
|
|
try {
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 10_000, signal: controller.signal });
|
|
expect(result.exitCode).toBe(124);
|
|
expect(pids()).toHaveLength(2);
|
|
expect(pids().every((pid) => !alive(pid))).toBe(true);
|
|
expect(fs.existsSync(tempHome())).toBe(false);
|
|
} finally { clearTimeout(timer); }
|
|
});
|
|
}, 10_000);
|
|
|
|
test('an already aborted call never launches Codex', async () => {
|
|
await withFakeCodex('process.exit(0);', async ({ skillDir, pids }) => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', signal: controller.signal });
|
|
expect(result.exitCode).toBe(124);
|
|
expect(pids()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test('a real error exit survives an inherited stderr stall', async () => {
|
|
await withFakeCodex(childHoldingPipes(false, 2), async ({ skillDir, pids, tempHome }) => {
|
|
const started = Date.now();
|
|
const result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 });
|
|
expect(result.exitCode).toBe(2);
|
|
expect(result.stderr).toContain('invalid fixture metadata');
|
|
expect(Date.now() - started).toBeLessThan(8_000);
|
|
expect(pids().every((pid) => !alive(pid))).toBe(true);
|
|
expect(fs.existsSync(tempHome())).toBe(false);
|
|
});
|
|
}, 10_000);
|
|
|
|
test('exit zero with an escaped stderr holder fails closed and preserves captured evidence', async () => {
|
|
await withFakeCodex(childHoldingPipes(true, 0), async ({ skillDir, tempHome }) => {
|
|
const started = Date.now();
|
|
let failure: unknown;
|
|
try { await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 2_000 }); }
|
|
catch (error) { failure = error; }
|
|
expect(failure).toBeInstanceOf(CodexHarnessError);
|
|
const error = failure as CodexHarnessError;
|
|
expect(error.message).toContain('output drain exceeded 5000ms');
|
|
expect(error.result?.exitCode).toBe(0);
|
|
expect(error.result?.stderr).toContain('invalid fixture metadata');
|
|
expect(Date.now() - started).toBeLessThan(8_000);
|
|
expect(fs.existsSync(tempHome())).toBe(false);
|
|
});
|
|
}, 10_000);
|
|
|
|
test('spawn errors reject instead of masquerading as a successful empty result', async () => {
|
|
await withFakeCodex('process.exit(0);', async ({ skillDir, dir, pids }) => {
|
|
await expect(runCodexSkill({ skillDir, prompt: 'fixture', cwd: path.join(dir, 'missing'), timeoutMs: 2_000 }))
|
|
.rejects.toBeInstanceOf(CodexHarnessError);
|
|
expect(pids()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
test('binary lookup time counts against the same process budget', async () => {
|
|
await withFakeCodex('process.exit(0);', async ({ skillDir, shimDir, pids }) => {
|
|
fs.writeFileSync(path.join(shimDir, 'which'), '#!/bin/bash\nexec sleep 2\n', { mode: 0o755 });
|
|
const started = Date.now();
|
|
// Bun caches executable resolution within a process. A fresh process
|
|
// must see this fake `which` before any earlier lookup caches /usr/bin/which.
|
|
const script = `
|
|
import { runCodexSkill } from ${JSON.stringify(path.join(import.meta.dir, 'helpers', 'codex-session-runner.ts'))};
|
|
console.log(JSON.stringify(await runCodexSkill({ skillDir: ${JSON.stringify(skillDir)}, prompt: 'fixture', timeoutMs: 100 })));
|
|
`;
|
|
const child = Bun.spawnSync([process.execPath, '-e', script], { env: process.env, timeout: 2_000 });
|
|
expect(child.exitCode, child.stderr.toString()).toBe(0);
|
|
const result = JSON.parse(child.stdout.toString());
|
|
expect(result.exitCode).toBe(124);
|
|
expect(Date.now() - started).toBeLessThan(1_500);
|
|
expect(pids()).toEqual([]);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Module mocks live in a fresh Bun process so they cannot affect this file's
|
|
// real fake-executable cases or any other free test in the parent shard.
|
|
describe('Codex stream terminal events', () => {
|
|
const cases = [
|
|
{ stream: 'stdout', fault: 'none', exitCode: 0 },
|
|
...(['stdout', 'stderr'] as const).flatMap(stream =>
|
|
(['premature-close', 'error'] as const).flatMap(fault =>
|
|
[0, 2, 124].map(exitCode => ({ stream, fault, exitCode })))),
|
|
];
|
|
let fixtureDir = '';
|
|
let observations: any[];
|
|
|
|
beforeAll(() => {
|
|
fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-stream-events-'));
|
|
const script = path.join(fixtureDir, 'stream-events.test.ts');
|
|
const output = path.join(fixtureDir, 'observations.json');
|
|
fs.writeFileSync(script, `
|
|
import { mock, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { EventEmitter } from 'node:events';
|
|
import { PassThrough } from 'node:stream';
|
|
const fixtureDir = ${JSON.stringify(fixtureDir)};
|
|
const cases = ${JSON.stringify(cases)};
|
|
const skillDir = path.join(fixtureDir, 'skill');
|
|
const temporaryDir = path.join(fixtureDir, 'temporary');
|
|
const operatorConfig = path.join(fixtureDir, 'operator-codex');
|
|
for (const dir of [skillDir, temporaryDir, operatorConfig]) fs.mkdirSync(dir);
|
|
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\\nname: fixture\\ndescription: fixture\\n---\\nReview the fixture.\\n');
|
|
process.env.TMPDIR = temporaryDir;
|
|
process.env.CODEX_HOME = operatorConfig;
|
|
let active;
|
|
mock.module('child_process', () => ({
|
|
spawn(command, args, options) {
|
|
if (command !== 'codex') throw new Error('Unexpected executable');
|
|
const current = active;
|
|
current.spawns++;
|
|
current.home = options.env.HOME;
|
|
queueMicrotask(() => {
|
|
const { child, scenario } = current;
|
|
child.stdout.write(JSON.stringify({ type: 'item.completed', item: {
|
|
type: 'command_execution', command: 'git diff', aggregated_output: '', exit_code: 0, status: 'completed',
|
|
} }) + '\\n');
|
|
child.stdout.write(JSON.stringify({ type: 'item.completed', item: {
|
|
type: 'agent_message', text: 'The gstack review found no issues in the current branch diff.',
|
|
} }) + '\\n');
|
|
child.stderr.write('retained fixture stderr\\n');
|
|
const affected = child[scenario.stream];
|
|
const other = child[scenario.stream === 'stdout' ? 'stderr' : 'stdout'];
|
|
if (scenario.fault === 'none') affected.end();
|
|
else if (scenario.fault === 'premature-close') affected.destroy();
|
|
else {
|
|
// The runner must preserve the first real stream error when another
|
|
// stream subsequently reports an error during cleanup.
|
|
affected.once('error', () => other.emit('error', new Error('secondary cleanup error')));
|
|
affected.destroy(new Error(scenario.stream + ' primary stream failure'));
|
|
}
|
|
other.end();
|
|
child.exitCode = scenario.exitCode;
|
|
child.emit('exit', scenario.exitCode, null);
|
|
});
|
|
return current.child;
|
|
},
|
|
spawnSync: () => { throw new Error('Unexpected synchronous subprocess'); },
|
|
execFileSync: () => { throw new Error('Unexpected synchronous subprocess'); },
|
|
}));
|
|
mock.module(${JSON.stringify(path.resolve(import.meta.dir, '../scripts/test-strict-output.ts'))}, () => ({
|
|
killProcessGroup(child, signal) {
|
|
if (child !== active.child) throw new Error('Unknown child');
|
|
active.kills.push(signal);
|
|
},
|
|
}));
|
|
Bun.spawnSync = (args) => {
|
|
if (JSON.stringify(args) !== JSON.stringify(['which', 'codex'])) throw new Error('Unexpected binary lookup');
|
|
active.lookups++;
|
|
return { exitCode: 0, stdout: Buffer.from('/fixture/codex\\n'), stderr: Buffer.alloc(0), signalCode: null };
|
|
};
|
|
const { runCodexSkill } = await import(${JSON.stringify(path.join(import.meta.dir, 'helpers/codex-session-runner.ts'))});
|
|
const { runRecordedCodexEval, validateCodexReview } = await import(${JSON.stringify(path.join(import.meta.dir, 'helpers/codex-eval.ts'))});
|
|
test('observe real runner and recorder with controlled stream lifecycles', async () => {
|
|
const observations = [];
|
|
for (const scenario of cases) {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
pid: undefined, stdout: new PassThrough(), stderr: new PassThrough(), exitCode: null,
|
|
kill() { throw new Error('Group cleanup must be intercepted, never sent to an OS process'); },
|
|
});
|
|
const events = [];
|
|
for (const name of ['stdout', 'stderr']) {
|
|
for (const event of ['end', 'close', 'error']) child[name].on(event, () => events.push(name + ':' + event));
|
|
}
|
|
active = { child, scenario, spawns: 0, lookups: 0, kills: [], home: '' };
|
|
const records = [];
|
|
let result;
|
|
let failure;
|
|
try {
|
|
await runRecordedCodexEval({
|
|
name: 'stream-fixture', suite: 'codex-stream-fixture', budgetMs: 1000,
|
|
run: async signal => {
|
|
result = await runCodexSkill({ skillDir, prompt: 'fixture', timeoutMs: 1000, signal });
|
|
return result;
|
|
},
|
|
validate: validateCodexReview,
|
|
record: entry => records.push(entry),
|
|
});
|
|
} catch (error) { failure = error; result ??= error.result; }
|
|
const beforeLate = JSON.stringify({ result, records });
|
|
const observedEvents = [...events];
|
|
const eof = { stdout: child.stdout.readableEnded, stderr: child.stderr.readableEnded };
|
|
// Cleanup retains harmless error listeners. Late notifications cannot
|
|
// mutate an already returned result or manufacture another attempt.
|
|
child.emit('error', new Error('late process error'));
|
|
child.emit('exit', 99, null);
|
|
for (const name of ['stdout', 'stderr']) {
|
|
child[name].emit('end');
|
|
child[name].emit('close');
|
|
child[name].emit('error', new Error('late stream error'));
|
|
child[name].emit('data', 'late stream data');
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
observations.push({
|
|
scenario, result, records, events: observedEvents, eof,
|
|
failure: failure ? { name: failure.name, message: failure.message } : null,
|
|
lateUnchanged: beforeLate === JSON.stringify({ result, records }),
|
|
spawns: active.spawns, lookups: active.lookups, kills: active.kills,
|
|
temporaryHomeRemoved: !!active.home && !fs.existsSync(active.home),
|
|
streamsDestroyed: child.stdout.destroyed && child.stderr.destroyed,
|
|
});
|
|
}
|
|
fs.writeFileSync(${JSON.stringify(output)}, JSON.stringify(observations));
|
|
}, 5000);
|
|
`);
|
|
const child = spawnSync(process.execPath, ['test', script, '--timeout=5000'], {
|
|
encoding: 'utf8', timeout: 10_000,
|
|
});
|
|
expect(child.status, child.stderr || child.stdout).toBe(0);
|
|
observations = JSON.parse(fs.readFileSync(output, 'utf8'));
|
|
expect(observations).toHaveLength(cases.length);
|
|
}, 15_000);
|
|
|
|
afterAll(() => {
|
|
if (fixtureDir) fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
});
|
|
|
|
for (const [index, scenario] of cases.entries()) {
|
|
test(`${scenario.stream} ${scenario.fault} with exit ${scenario.exitCode}`, () => {
|
|
const observation = observations[index];
|
|
expect(observation.spawns).toBe(1);
|
|
expect(observation.lookups).toBe(1);
|
|
expect(observation.kills).toEqual(['SIGKILL']);
|
|
expect(observation.temporaryHomeRemoved).toBe(true);
|
|
expect(observation.streamsDestroyed).toBe(true);
|
|
expect(observation.lateUnchanged).toBe(true);
|
|
expect(observation.records).toHaveLength(1);
|
|
expect(observation.result).toMatchObject({
|
|
exitCode: scenario.exitCode,
|
|
output: 'The gstack review found no issues in the current branch diff.',
|
|
stderr: 'retained fixture stderr\n',
|
|
});
|
|
const entry = observation.records[0];
|
|
if (scenario.fault === 'none') {
|
|
expect(observation.failure).toBeNull();
|
|
expect(observation.eof).toEqual({ stdout: true, stderr: true });
|
|
expect(entry).toMatchObject({ passed: true, exit_reason: 'success' });
|
|
} else {
|
|
expect(entry.passed).toBe(false);
|
|
expect(entry.error).toContain('retained fixture stderr');
|
|
if (scenario.exitCode !== 0) {
|
|
expect(entry.exit_reason).toBe(scenario.exitCode === 124 ? 'timeout' : `exit_code_${scenario.exitCode}`);
|
|
expect(observation.failure.message).toContain(`Codex exited with code ${scenario.exitCode}`);
|
|
} else {
|
|
expect(observation.failure.name).toBe('CodexHarnessError');
|
|
expect(entry.exit_reason).toBe('harness_error');
|
|
if (scenario.fault === 'error') {
|
|
expect(entry.error).toContain(`${scenario.stream} primary stream failure`);
|
|
expect(entry.error).not.toContain('secondary cleanup error');
|
|
} else {
|
|
expect(observation.eof[scenario.stream]).toBe(false);
|
|
expect(observation.events).not.toContain(`${scenario.stream}:end`);
|
|
expect(observation.events).not.toContain(`${scenario.stream}:error`);
|
|
expect(entry.error).toContain(scenario.stream);
|
|
expect(entry.error).toContain('before EOF');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|