mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Scope headless detection to the path that opens a browser
The new self-detection ran before mode dispatch, so it also caught --wait, --stop, and --schema. CI failed on the start/wait cycle test: --wait returned 2 (no browser) where the documented poll loop expects 3 (WAITING). Under CI=1 the suite went 4 pass / 2 fail; it is 6 / 0 now. Two of those modes were user-facing bugs, not just test breakage. --stop exited 2 without killing the daemon it was asked to kill, leaking a server process (verified: one daemon running, CI=1 --stop, still one). --schema only prints a payload example, and new-work.md tells the agent to read it before building a payload. Detection can only tell whether this process can auto-open a browser, not whether the user has one: SSH with a forwarded port and a harness with an in-app browser both have a browser and no DISPLAY. The file already treats serve-without-opening as first class, since --start spawns its own daemon with --no-open. So the check now gates acquiring a session, not managing or ending one. The blocking serve path still exits 2 on a headless box, with a test pinning that. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -75,9 +75,14 @@ if (process.env.IMPECCABLE_QUESTION_DISABLED) {
|
||||
console.log('serve-question: disabled in this session (no browser); use the structured question tool instead.');
|
||||
process.exit(2);
|
||||
}
|
||||
// Headless self-detection: --no-open means the caller opens the URL itself,
|
||||
// so only environments that would need a browser opened for them are checked.
|
||||
if (!process.argv.includes('--no-open') && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
// Headless self-detection, applied only where a browser is actually wanted.
|
||||
// --no-open means the caller opens the URL itself, and --wait / --stop /
|
||||
// --schema never open anything: --wait polls a daemon whose browser question
|
||||
// was already settled at --start, --stop kills one, --schema prints text. A
|
||||
// spurious exit 2 from those breaks the documented loop, which polls --wait
|
||||
// while it exits 3 and reads --schema before building a payload.
|
||||
const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema');
|
||||
if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
|
||||
const headless =
|
||||
process.env.CI ||
|
||||
(process.env.SSH_CONNECTION && !process.env.DISPLAY) ||
|
||||
|
||||
@@ -90,6 +90,51 @@ describe('serve-question', () => {
|
||||
assert.match(collected.out, /"optionId":"assigned"/);
|
||||
});
|
||||
|
||||
it('headless detection spares the modes that never open a browser', async () => {
|
||||
// Only the blocking serve path auto-opens a URL. --wait polls a daemon
|
||||
// that is already running, --stop kills one, --schema just prints text,
|
||||
// so a headless environment must not turn any of them into exit 2: the
|
||||
// documented flow polls --wait while it exits 3, and new-work.md tells
|
||||
// the agent to read --schema first.
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
||||
const payloadPath = path.join(dir, 'q.json');
|
||||
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
|
||||
const headlessEnv = { ...process.env, CI: '1' };
|
||||
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
|
||||
const run = (args) => new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd: dir, env: headlessEnv, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
let out = '';
|
||||
child.stdout.on('data', (chunk) => { out += chunk; });
|
||||
child.on('exit', (code) => resolve({ code, out }));
|
||||
});
|
||||
|
||||
const schema = await run(['--schema']);
|
||||
assert.equal(schema.code, 0, `--schema under CI must print, got ${schema.code}: ${schema.out}`);
|
||||
|
||||
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', 'hk']);
|
||||
assert.equal(started.code, 0, started.out);
|
||||
try {
|
||||
const waiting = await run(['--wait', '--key', 'hk', '--poll', '1']);
|
||||
assert.equal(waiting.code, 3, `--wait under CI must report WAITING, got ${waiting.code}: ${waiting.out}`);
|
||||
} finally {
|
||||
const stopped = await run(['--stop', '--key', 'hk']);
|
||||
assert.equal(stopped.code, 0, `--stop under CI must kill the daemon, got ${stopped.code}: ${stopped.out}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('headless detection still blocks the path that would open a browser', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
||||
const payloadPath = path.join(dir, 'q.json');
|
||||
writeFileSync(payloadPath, JSON.stringify(PAYLOAD));
|
||||
const headlessEnv = { ...process.env, CI: '1' };
|
||||
delete headlessEnv.IMPECCABLE_QUESTION_FORCE;
|
||||
const code = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, '--payload', payloadPath], { cwd: dir, env: headlessEnv, stdio: 'ignore' });
|
||||
child.on('exit', resolve);
|
||||
});
|
||||
assert.equal(code, 2);
|
||||
});
|
||||
|
||||
it('rejects an empty payload', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'serve-question-'));
|
||||
const payloadPath = path.join(dir, 'q.json');
|
||||
|
||||
Reference in New Issue
Block a user