mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 23:21:53 +02:00
* fix(browse): prepare reliable cookie import wave for validation * ci: sequence quality and behavior for validation branch * fix(browse): isolate Windows qualification and preserve native diagnostics * test(browse): cover cookie workflow quality and isolate Windows user paths * test(browse): trace native member startup and initialize fresh folders * fix(browse): keep Windows member stdin alive through EOF * fix(browse): latch native timeouts and compare contained Edge startup * test(browse): verify native version metadata and actual Windows argv * test(browse): qualify Dia import on isolated macOS CI * fix(browse): require picker origin for session mutations * fix(browse): bound credential reads through stream completion * test(browse): inspect owned Windows process arguments natively * test(evals): preserve passing coverage during cookie repair reruns * test(browse): isolate Dia qualification in a fresh macOS account * test(browse): pass bounded integer timeouts to native Mac probes * test(browse): distinguish Windows profile initialization from containment * test(browse): await descendant pipe readiness before parent exit * test(browse): initialize and restore isolated macOS Keychain state * test(browse): initialize Windows fixture folders before qualification * test(ci): pin the same Node runtime across Windows checks * test(browse): distinguish native macOS browser preflight stages * test(browse): isolate Windows descendant console lifetime * test(browse): preserve native receipts and identify fixture lock holders * test(browse): prepare dependency resolution before native Mac worker startup * test(ci): include lock and close checks in native diagnostics * test(browse): preserve native owner probe stages and subprocess deadlines * fix(browse): classify Chromium profile-in-use exit precisely * test(browse): retain Mac qualification evidence through cleanup failures * test(browse): bound Mac fixture paths and retire its owned user domain * test(browse): accept vanished fixture entries without weakening cleanup * test(browse): identify probe-created macOS user domains safely * test(browse): observe Mac user domains without targeting them first * test(browse): use passive fresh-user ownership throughout Mac qualification * test(browse): distinguish profile and registered-home Keychain lookups * test(browse): qualify Dia under one registered account home * test(browse): identify Dia startup and owned process-group failures * test(browse): classify bounded Dia startup diagnostics without leaking output * fix(test): preserve native Mac sandboxing and reap owned browser children * fix(browse): preserve Chromium sandboxing for native profile imports * test(browse): inspect signed Mach-O architecture without launching Xcode tools * test(browse): sample pending Dia startup and reap on all cleanup paths * test(browse): compare protected Dia launches in fresh Bun and Node accounts * test(browse): inspect isolated Mac GUI readiness without browser access * v1.90.0.0 fix: bind cookie picker actions to their document * test: validate cookie guards and fit nested launch fixtures * ci: configure the bundled Chromium sandbox helper * fix(browse): classify Playwright authentication timeouts * test: retain bounded Windows lifecycle diagnostics * test(cso): reuse bounded NTFS precision candidates * test(review): handle explicit preservation choices safely * test(browse): remove owned fixture directories with explicit primitives * test(review): distinguish descriptive reuse from edit commitments * test: admit only the approved unscored cookie workflow refusal * test: keep the Office Hours judge mock export-complete * fix: keep dependency-free CI planners independent of the model SDK * test: observe the exact holder after a native fixture unlink failure * fix: start seeded PTY observations at owned readiness * test: acquire identity-bound Windows deletion admission before profile resets * test: preserve qualified Git index bits without authorizing mutations
211 lines
14 KiB
TypeScript
211 lines
14 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 } 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',
|
|
'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', '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]
|
|
?? '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 inheritedTerm of ['dumb', '', 'xterm-256color']) test.skipIf(process.platform === 'win32')(`actual PTY launcher carries placeholder styling into owned seed submission: ${inheritedTerm || 'empty TERM'}`, 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', TERM: inheritedTerm } });
|
|
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);
|
|
} 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);
|