mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
293 lines
19 KiB
TypeScript
293 lines
19 KiB
TypeScript
import { expect, test } from 'bun:test';
|
||
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { submitPlanSeed, PlanSeedTimeout } from './helpers/plan-seed-submission';
|
||
import { PtyCurrentScreen } from './helpers/pty-current-screen';
|
||
import { launchClaudePty, runPlanSkillObservation, isProseAUQVisible, isNumberedOptionListVisible, isPermissionDialogVisible } from './helpers/claude-pty-runner';
|
||
|
||
// A real PTY process consumes the actual paste/Enter/slash bytes and publishes
|
||
// its own PID status and transcript. No provider or runner hooks are installed.
|
||
const CLI = fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'plan-seed-cli.ts'), 'utf8');
|
||
|
||
for (const scenario of ['success', 'completed-tool', 'status-updating', 'history-empty-box',
|
||
'native-paste', 'native-paste-block', 'native-paste-changed', 'native-paste-fused',
|
||
'native-paste-mismatched', 'native-paste-duplicate', 'native-paste-appended', 'native-paste-multiple-blocks',
|
||
'startup-placeholder', 'startup-placeholder-cursor', 'startup-placeholder-unicode',
|
||
'startup-typed-hint', 'startup-partial-dim', 'startup-prior-conversation', 'startup-missing-styles',
|
||
'startup-waiting', 'startup-prose-question', 'startup-permission', 'startup-fresh-waiting',
|
||
'no-ack', 'fused', 'duplicate', 'session-switch', 'foreign-cwd',
|
||
'pending-tool', 'question', 'prose-question', 'permission', 'no-end-turn', 'partial', 'wrong-pid',
|
||
'typed-current', 'multiline-current', 'history-box-typed-current', 'history-box-multiline-current',
|
||
'missing-current-top', 'missing-current-bottom', 'mismatched-current-rules', 'unframed-current',
|
||
'history-no-current', 'history-missing-current-top', 'history-missing-current-bottom',
|
||
'stray-prompt-after-current', 'stale-response-frame',
|
||
...(process.platform === 'linux' ? ['wrong-start', 'wrong-domain'] : [])]) {
|
||
test.skipIf(process.platform === 'win32')(`seed submission owns each protocol step: ${scenario}`, async () => {
|
||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-')));
|
||
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
|
||
const script = path.join(dir, 'cli.ts'); fs.writeFileSync(script, CLI);
|
||
const decoder = new PtyCurrentScreen({ cols: 120, rows: 40 });
|
||
let raw = '', exited = false;
|
||
const launchedAt = Date.now();
|
||
const proc = Bun.spawn([process.execPath, script], {
|
||
cwd: dir, env: { ...process.env, CLAUDE_CONFIG_DIR: config, SEED_CASE: scenario },
|
||
terminal: { cols: 120, rows: 40, data(_terminal, data) { const s = Buffer.from(data).toString(); raw += s; decoder.feed(s); } },
|
||
onExit() { exited = true; },
|
||
});
|
||
const sent: string[] = [];
|
||
const session = {
|
||
pid: () => proc.pid, exited: () => exited, hermeticConfigDir: config,
|
||
send(s: string) { sent.push(s); proc.terminal!.write(s); },
|
||
sendKey(key: string) { expect(key).toBe('Enter'); sent.push('\r'); proc.terminal!.write('\r'); },
|
||
mark: () => raw.length,
|
||
currentScreen: async () => { const mark = raw.length; const frame = await decoder.snapshot();
|
||
if (scenario === 'startup-fresh-waiting') {
|
||
const statusFile = path.join(config, 'sessions', `${proc.pid}.json`);
|
||
const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
|
||
fs.writeFileSync(statusFile, JSON.stringify({ ...status, waitingFor: 'permission prompt' }));
|
||
}
|
||
return { text: frame.text, rawEnd: scenario === 'stale-response-frame' && sent.includes('\r') ? mark - 1 : mark,
|
||
...(scenario === 'startup-missing-styles' ? {} : { styledText: frame.styledText }) }; },
|
||
};
|
||
const seed = 'Please review when I run the skill:\n\n# Plan\nKeep $HOME and `literal` text.\n';
|
||
const deadlineAt = launchedAt + 1100;
|
||
try {
|
||
let failure: unknown;
|
||
try { await submitPlanSeed(session, seed, { cwd: dir, launchedAt, deadlineAt,
|
||
isQuestionOrPermission: text => isProseAUQVisible(text) || isNumberedOptionListVisible(text) || isPermissionDialogVisible(text) }); }
|
||
catch (error) { failure = error; }
|
||
if (['success', 'completed-tool', 'status-updating', 'history-empty-box', 'native-paste', 'native-paste-block', 'startup-placeholder', 'startup-placeholder-cursor', 'startup-placeholder-unicode'].includes(scenario)) {
|
||
expect(failure).toBeUndefined();
|
||
session.send('/plan-eng-review\r');
|
||
await Bun.sleep(50);
|
||
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
|
||
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
|
||
expect(sent).toEqual([`\x1b[200~${seed}\x1b[201~`, '\r', '/plan-eng-review\r']);
|
||
} else {
|
||
expect(failure).toBeInstanceOf(Error);
|
||
const expected = ({ fused: 'fused, duplicated, or changed', duplicate: 'fused, duplicated, or changed',
|
||
'session-switch': 'native session changed', 'foreign-cwd': 'Foreign cwd',
|
||
question: 'requires an answer', 'prose-question': 'requires an answer', 'wrong-pid': 'does not match this launch',
|
||
'wrong-start': 'native process identity changed', 'wrong-domain': 'native process identity changed' } as Record<string, string>)[scenario]
|
||
?? (scenario.startsWith('native-paste') ? 'fused, duplicated, or changed' : 'existing case budget');
|
||
expect((failure as Error).message).toContain(expected);
|
||
expect(sent.some(s => s === '/plan-eng-review\r')).toBe(false);
|
||
expect(sent.filter(s => s === '\r').length).toBeLessThanOrEqual(1);
|
||
if (scenario.startsWith('startup-')) expect(sent).toEqual([]);
|
||
}
|
||
expect(Date.now() - deadlineAt).toBeLessThan(500);
|
||
} finally {
|
||
if (!exited) proc.kill();
|
||
await proc.exited;
|
||
proc.terminal?.close(); decoder.dispose();
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}, 6000);
|
||
}
|
||
|
||
for (const entry of [
|
||
...['dumb', '', 'xterm-256color'].map(TERM => ({ name: TERM || 'empty TERM', env: { TERM } })),
|
||
{ name: 'CI', env: { CI: '1' } },
|
||
{ name: 'CI with explicit disabled color', env: { CI: '1', FORCE_COLOR: '0' } },
|
||
{ name: 'CI with explicit NO_COLOR', env: { CI: '1', NO_COLOR: '1' } },
|
||
{ name: 'CI with all explicit conflicting terminal knobs', env: { CI: '1', TERM: 'dumb', COLORTERM: '', FORCE_COLOR: '0', NO_COLOR: '1' } },
|
||
]) test.skipIf(process.platform === 'win32')(`actual PTY launcher carries placeholder styling into owned seed submission: ${entry.name}`, async () => {
|
||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-launcher-')));
|
||
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
|
||
const script = path.join(dir, 'cli.ts'); fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
|
||
const old = process.env.BROWSE_TERMINAL_BINARY; process.env.BROWSE_TERMINAL_BINARY = script;
|
||
const launchedAt = Date.now(); let session: Awaited<ReturnType<typeof launchClaudePty>> | undefined;
|
||
try {
|
||
session = await launchClaudePty({ cwd: dir, observeScreen: true, permissionMode: 'plan', timeoutMs: 4000, model: 'fixture',
|
||
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: 'startup-terminal-placeholder-cursor', ...entry.env } });
|
||
const seed = '# Real launcher seed\nKeep this exact plan.';
|
||
await submitPlanSeed({...session, currentScreen: session.currentScreenFrame}, seed, { cwd: dir, launchedAt, deadlineAt: launchedAt + 2500,
|
||
isQuestionOrPermission: text => isProseAUQVisible(text) || isNumberedOptionListVisible(text) || isPermissionDialogVisible(text) });
|
||
session.send('/plan-eng-review\r'); await Bun.sleep(50);
|
||
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
|
||
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
|
||
const launch = JSON.parse(fs.readFileSync(path.join(config, 'launch.json'), 'utf8'));
|
||
expect(launch.terminalEnv.TERM).toBe('xterm-256color');
|
||
expect(launch.terminalEnv.FORCE_COLOR).toBe('1');
|
||
for (const key of ['CI', 'COLORTERM', 'NO_COLOR']) {
|
||
if (key in entry.env) expect(launch.terminalEnv[key]).toBe(entry.env[key]);
|
||
}
|
||
} finally {
|
||
try { await session?.close(); }
|
||
finally {
|
||
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY; else process.env.BROWSE_TERMINAL_BINARY = old;
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
}, 6000);
|
||
|
||
for (const terminalEnv of [
|
||
{ CI: '1', TERM: 'dumb', COLORTERM: '', FORCE_COLOR: '0', NO_COLOR: '1' },
|
||
{ CI: '1', TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3', NO_COLOR: '' },
|
||
]) test.skipIf(process.platform === 'win32')(`unobserved PTY preserves explicit terminal environment: ${terminalEnv.FORCE_COLOR}`, async () => {
|
||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-unobserved-')));
|
||
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
|
||
const script = path.join(dir, 'cli.ts'); fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
|
||
const old = process.env.BROWSE_TERMINAL_BINARY; process.env.BROWSE_TERMINAL_BINARY = script;
|
||
let session: Awaited<ReturnType<typeof launchClaudePty>> | undefined;
|
||
try {
|
||
session = await launchClaudePty({ cwd: dir, timeoutMs: 4000, model: 'fixture',
|
||
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: 'startup-terminal-placeholder-cursor', ...terminalEnv } });
|
||
await session.waitFor('❯', { timeoutMs: 2000 });
|
||
const launch = JSON.parse(fs.readFileSync(path.join(config, 'launch.json'), 'utf8'));
|
||
expect(launch.terminalEnv).toEqual(terminalEnv);
|
||
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
|
||
} finally {
|
||
try { await session?.close(); }
|
||
finally {
|
||
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY; else process.env.BROWSE_TERMINAL_BINARY = old;
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
}, 6000);
|
||
|
||
for (const entry of [
|
||
{ name: 'empty placeholder', observeScreen: true, scenario: 'startup-ci-placeholder', submits: true },
|
||
{ name: 'typed draft', observeScreen: true, scenario: 'startup-ci-typed-hint', submits: false },
|
||
{ name: 'unobserved session', observeScreen: false, scenario: 'startup-ci-placeholder', submits: false },
|
||
]) test.skipIf(process.platform === 'win32')(`actual PTY launcher preserves CI seed safety: ${entry.name}`, async () => {
|
||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-ci-')));
|
||
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
|
||
const script = path.join(dir, 'cli.ts'); fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
|
||
const old = process.env.BROWSE_TERMINAL_BINARY; process.env.BROWSE_TERMINAL_BINARY = script;
|
||
const launchedAt = Date.now(); let session: Awaited<ReturnType<typeof launchClaudePty>> | undefined;
|
||
try {
|
||
session = await launchClaudePty({ cwd: dir, observeScreen: entry.observeScreen, permissionMode: 'plan', timeoutMs: 4000, model: 'fixture',
|
||
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: entry.scenario, CI: 'true', TERM: 'dumb', FORCE_COLOR: '0' } });
|
||
if (entry.observeScreen) {
|
||
const seed = '# CI seed\nPreserve this exact draft.';
|
||
const submission = submitPlanSeed({...session, currentScreen: session.currentScreenFrame}, seed, { cwd: dir, launchedAt, deadlineAt: launchedAt + 2500,
|
||
isQuestionOrPermission: text => isProseAUQVisible(text) || isNumberedOptionListVisible(text) || isPermissionDialogVisible(text) });
|
||
if (entry.submits) {
|
||
await submission;
|
||
session.send('/plan-eng-review\r'); await Bun.sleep(50);
|
||
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
|
||
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
|
||
} else {
|
||
await expect(submission).rejects.toBeInstanceOf(PlanSeedTimeout);
|
||
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
|
||
}
|
||
} else {
|
||
for (let i = 0; i < 50 && !fs.existsSync(path.join(config, 'launch.json')); i++) await Bun.sleep(10);
|
||
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
|
||
}
|
||
const launch = JSON.parse(fs.readFileSync(path.join(config, 'launch.json'), 'utf8'));
|
||
expect(launch.term).toBe(entry.observeScreen ? 'xterm-256color' : 'dumb');
|
||
expect(launch.forceColor).toBe(entry.observeScreen ? '1' : '0');
|
||
} finally {
|
||
try { await session?.close(); }
|
||
finally {
|
||
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY; else process.env.BROWSE_TERMINAL_BINARY = old;
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
}, 6000);
|
||
|
||
for (const mode of ['unseeded-deadline', 'seeded-deadline', 'protocol-error']) test.skipIf(process.platform === 'win32')(`actual observation caller preserves preflight outcome: ${mode}`, async () => {
|
||
const seeded = mode !== 'unseeded-deadline';
|
||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-seed-budget-')));
|
||
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
|
||
const script = path.join(dir, 'cli.ts');
|
||
fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
|
||
const artifactRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-timeout-artifacts-'));
|
||
const priorEval = { EVALS_RUN_ID: process.env.EVALS_RUN_ID, GSTACK_EVAL_DIR: process.env.GSTACK_EVAL_DIR };
|
||
process.env.EVALS_RUN_ID = 'seed-timeout'; process.env.GSTACK_EVAL_DIR = artifactRoot;
|
||
const old = process.env.BROWSE_TERMINAL_BINARY;
|
||
process.env.BROWSE_TERMINAL_BINARY = script;
|
||
try {
|
||
const run = runPlanSkillObservation({ skillName: 'plan-eng-review', cwd: dir,
|
||
...(seeded ? { initialPlanContent: '# Exact plan\nNo new work allowance.' } : {}), timeoutMs: mode === 'protocol-error' ? 10000 : 600, model: 'fixture',
|
||
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: mode === 'protocol-error' ? 'wrong-pid' : mode === 'seeded-deadline' ? 'no-ack' : 'success' } });
|
||
if (mode === 'protocol-error') {
|
||
await expect(run).rejects.toThrow('Plan seed PID status does not match this launch');
|
||
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
|
||
return;
|
||
}
|
||
const obs = await run;
|
||
expect(obs.outcome).toBe('timeout');
|
||
expect(obs.summary).toContain('existing case budget');
|
||
expect(obs.scopeGateAutoSelectObserved).toBe(false);
|
||
expect(obs.elapsedMs).toBeLessThan(1600);
|
||
expect(obs.artifactDir).toStartWith(artifactRoot);
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
const saved = JSON.parse(fs.readFileSync(path.join(obs.artifactDir!, 'observation.json'), 'utf8'));
|
||
expect(saved.state).toBe('plan_skill_preflight_timeout');
|
||
expect(saved.summary).toBe(obs.summary);
|
||
if (!seeded) expect(saved.viewportError).toContain('screen observation was not enabled');
|
||
else expect(saved.viewportError).toBeUndefined();
|
||
for (const name of ['terminal.raw.log', 'terminal.visible.log', ...(seeded ? ['terminal.screen.log'] : [])]) {
|
||
expect(fs.existsSync(path.join(obs.artifactDir!, name))).toBe(true);
|
||
expect(fs.statSync(path.join(obs.artifactDir!, name)).mode & 0o777).toBe(0o600);
|
||
}
|
||
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
|
||
} finally {
|
||
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY;
|
||
else process.env.BROWSE_TERMINAL_BINARY = old;
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
fs.rmSync(artifactRoot, { recursive: true, force: true });
|
||
for (const [key, value] of Object.entries(priorEval)) {
|
||
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||
}
|
||
}
|
||
}, 15000);
|
||
|
||
for (const entry of [
|
||
{ name: 'plan seed', seeded: true, inPlanMode: true, extraArgs: [], env: {}, expectedHint: 'active' },
|
||
{ name: 'outside plan mode', inPlanMode: false, extraArgs: [], env: {}, expectedHint: null },
|
||
{ name: 'explicit inactive hint', inPlanMode: true, extraArgs: [], env: { GSTACK_PLAN_MODE: 'inactive' }, expectedHint: 'inactive' },
|
||
{ name: 'explicit outside hint', inPlanMode: false, extraArgs: [], env: { GSTACK_PLAN_MODE: 'active' }, expectedHint: 'active' },
|
||
{ name: 'separate CLI override', inPlanMode: true, extraArgs: ['--permission-mode', 'auto'], env: {}, expectedHint: null },
|
||
{ name: 'equals CLI override', inPlanMode: true, extraArgs: ['--permission-mode=default'], env: {}, expectedHint: null },
|
||
{ name: 'unrelated CLI option', inPlanMode: true, extraArgs: ['--disallowedTools', 'AskUserQuestion'], env: {}, expectedHint: 'active' },
|
||
]) test.skipIf(process.platform === 'win32')(`actual observation launch preserves initial plan hint: ${entry.name}`, async () => {
|
||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-mode-hint-')));
|
||
const config = path.join(dir, '.claude'); fs.mkdirSync(config);
|
||
const script = path.join(dir, 'cli.ts');
|
||
fs.writeFileSync(script, `#!${process.execPath}\n${CLI}`, { mode: 0o700 });
|
||
const old = process.env.BROWSE_TERMINAL_BINARY;
|
||
process.env.BROWSE_TERMINAL_BINARY = script;
|
||
try {
|
||
const plan = '# Existing plan\nKeep the review scope.';
|
||
const obs = await runPlanSkillObservation({ skillName: 'plan-eng-review', cwd: dir,
|
||
inPlanMode: entry.inPlanMode, extraArgs: entry.extraArgs,
|
||
...(entry.seeded ? { initialPlanContent: plan } : {}), timeoutMs: entry.seeded ? 12000 : 600, model: 'fixture',
|
||
env: { CLAUDE_CONFIG_DIR: config, SEED_CASE: entry.seeded ? 'observation-scope-hint' : 'success', ...entry.env } });
|
||
const launch = JSON.parse(fs.readFileSync(path.join(config, 'launch.json'), 'utf8'));
|
||
const sessionIndex = launch.argv.indexOf('--session-id');
|
||
if (entry.seeded) expect(launch.argv[sessionIndex + 1]).toMatch(/^[0-9a-f-]{36}$/);
|
||
else expect(sessionIndex).toBe(-1);
|
||
expect(entry.seeded ? launch.argv.slice(0, sessionIndex) : launch.argv).toEqual([
|
||
'--model', 'fixture', ...(entry.inPlanMode ? ['--permission-mode', 'plan'] : []),
|
||
'--strict-mcp-config', ...entry.extraArgs]);
|
||
expect(launch.planModeHint).toBe(entry.expectedHint);
|
||
expect(launch.planModeForce).toBeNull();
|
||
if (entry.seeded) {
|
||
expect(obs.outcome).toBe('asked');
|
||
// The fixture supplies no mode announcement: wiring proof is not native
|
||
// announcement-compliance proof and must not manufacture that flag.
|
||
expect(obs.scopeGateAutoSelectObserved).toBe(false);
|
||
const seed = `Keep this draft plan as context. Briefly acknowledge receipt, then wait for my next message containing a slash command. Do not start the review or call tools yet.\n\n${plan}`;
|
||
const events = fs.readFileSync(path.join(config, 'events.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||
expect(events.map(e => e.kind)).toEqual(['paste', 'enter', 'end_turn', 'slash']);
|
||
expect(events.slice(0, 3).every(e => e.value === seed)).toBe(true);
|
||
expect(events[3].value).toBe('/plan-eng-review\r');
|
||
} else {
|
||
expect(obs.outcome).toBe('timeout');
|
||
expect(fs.existsSync(path.join(config, 'events.jsonl'))).toBe(false);
|
||
}
|
||
} finally {
|
||
if (old === undefined) delete process.env.BROWSE_TERMINAL_BINARY;
|
||
else process.env.BROWSE_TERMINAL_BINARY = old;
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}, 18000);
|