mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807) * ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997) * fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805) * fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847) * feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096) Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com> * fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851) * fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906) * test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910) * chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes * v1.91.14.0: release metadata * test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group * ci: Codex login steps run under bash (the CI container's default sh rejects pipefail) * fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt * fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts * test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call * fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier * test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers) * test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts) * test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep) * fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit * test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently * test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census * fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic * test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output * fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens * test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design) * test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept) * fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files --------- Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com> Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com> Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
596 lines
28 KiB
TypeScript
596 lines
28 KiB
TypeScript
import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test';
|
|
import * as childProcess from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { publishBoard, type PublishBoardResult } from '../design/src/daemon-client';
|
|
import { CMDLINE_MARKER, readStateFile, verifyIdentity, writeStateFile } from '../design/src/daemon-state';
|
|
import { makeBoardHtml, spawnDaemonForTest, type SpawnedDaemon } from '../design/test/daemon-tests-fixtures';
|
|
import { createDesignReviewPicker, DESIGN_BOARD_ACTOR_PROTOCOL } from './helpers/plan-review-board-feedback';
|
|
import { pickPlanReviewQuestion } from './helpers/plan-review-cases';
|
|
import type { NativeQuestion } from './helpers/plan-skill-questions';
|
|
import captured from './fixtures/design-board-questions.json';
|
|
import outsideVoices from './fixtures/design-outside-voices-question.json';
|
|
import { usePrivateStateRoot } from './helpers/private-state-root';
|
|
|
|
usePrivateStateRoot();
|
|
|
|
let cwd: string;
|
|
let stateFile: string;
|
|
let daemon: SpawnedDaemon;
|
|
let daemons: SpawnedDaemon[];
|
|
let boardNumber: number;
|
|
|
|
beforeEach(async () => {
|
|
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-review-board-'));
|
|
stateFile = path.join(cwd, '.gstack', 'design.json');
|
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
daemons = [];
|
|
boardNumber = 0;
|
|
daemon = await spawnDaemonForTest({ stateFile });
|
|
daemons.push(daemon);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
for (const owned of daemons) await owned.stop();
|
|
} finally {
|
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function board(port = daemon.port): Promise<PublishBoardResult> {
|
|
const directory = path.join(cwd, `board-${++boardNumber}`);
|
|
fs.mkdirSync(directory);
|
|
return publishBoard({ port, html: makeBoardHtml(directory) });
|
|
}
|
|
|
|
// These are the declared actor records, not relabeled historical questions.
|
|
function question(url: string): NativeQuestion {
|
|
return { header: 'Board wait', question: `Did you submit? ${url}`, multiSelect: false, options: [
|
|
{ label: 'Submitted', description: 'I submitted feedback on the comparison board. Read its final feedback and continue.' },
|
|
{ label: 'Regenerate / Remix', description: 'I requested another round on the comparison board. Read that request and regenerate.' },
|
|
{ label: 'Type preferences', description: 'I will provide my preferences in chat instead of using the comparison board.' },
|
|
] };
|
|
}
|
|
|
|
const picker = (deadlineAt = Date.now() + 10_000) => createDesignReviewPicker({ cwd, deadlineAt });
|
|
const feedbackPath = (published: PublishBoardResult) => path.join(published.sourceDir, 'feedback.json');
|
|
async function expectUnsubmitted(published: PublishBoardResult) {
|
|
expect(fs.existsSync(feedbackPath(published))).toBe(false);
|
|
const response = await fetch(published.url + 'api/progress');
|
|
expect(response.ok).toBe(true);
|
|
expect(await response.json()).toEqual({ status: 'serving' });
|
|
}
|
|
|
|
const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]];
|
|
test('the declared board protocol submits actual feedback before its acknowledgment in every offered order', async () => {
|
|
const untouched = await board();
|
|
for (const order of orders) {
|
|
const published = await board();
|
|
const menu = question(published.url);
|
|
menu.options = order.map(index => menu.options[index]!);
|
|
menu.options.forEach((option, index) => {
|
|
option.label = `${String.fromCharCode(65 + index)}) ${option.label}${index === order.indexOf(0) ? ' (recommended)' : ''}`;
|
|
});
|
|
const answer = picker()(menu);
|
|
expect(answer).toBe(order.indexOf(0) + 1);
|
|
// The real server must persist feedback before the synchronous callback
|
|
// returns; the native runner still owns the later question acknowledgment.
|
|
const written = JSON.parse(fs.readFileSync(feedbackPath(published), 'utf8'));
|
|
expect(written).toMatchObject({ preferred: 'A', ratings: {}, comments: {}, regenerated: false, boardId: published.id });
|
|
expect(written.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
expect(fs.existsSync(path.join(published.sourceDir, 'feedback-pending.json'))).toBe(false);
|
|
}
|
|
await expectUnsubmitted(untouched);
|
|
});
|
|
|
|
test('the native-only actor declares its scope and declines the complete captured outside-voices request', async () => {
|
|
const published = await board();
|
|
expect(DESIGN_BOARD_ACTOR_PROTOCOL).toContain('Decline the optional Design Outside Voices step');
|
|
expect(DESIGN_BOARD_ACTOR_PROTOCOL).toContain('all ordinary Design review decisions still apply');
|
|
expect(DESIGN_BOARD_ACTOR_PROTOCOL).toContain('header "Voices"');
|
|
expect(outsideVoices.actualAnswer).toBe('Yes, run outside design voices (recommended)');
|
|
for (const option of outsideVoices.question.options) {
|
|
expect(DESIGN_BOARD_ACTOR_PROTOCOL).toContain(option.label.replace(/ \(recommended\)$/, ''));
|
|
expect(DESIGN_BOARD_ACTOR_PROTOCOL).toContain(option.description);
|
|
}
|
|
const retained = structuredClone(outsideVoices.question) as NativeQuestion;
|
|
// The unchanged generic policy would repeat the actual affirmative choice.
|
|
expect(pickPlanReviewQuestion(retained)).toBe(1);
|
|
expect(picker()(retained)).toBe(2);
|
|
for (const order of [[0, 1], [1, 0]]) {
|
|
for (const decoration of ['plain', 'letter', 'parenthesized', 'bracketed']) {
|
|
const menu = structuredClone(retained);
|
|
menu.options = order.map((index, position) => ({ ...retained.options[index]!,
|
|
label: (decoration === 'letter' ? `${String.fromCharCode(65 + position)}) `
|
|
: decoration === 'parenthesized' ? `(${String.fromCharCode(65 + position)}) `
|
|
: decoration === 'bracketed' ? `[${String.fromCharCode(65 + position)}] ` : '')
|
|
+ retained.options[index]!.label.replace(/ \(recommended\)$/, '')
|
|
+ (position === 0 ? ' (recommended)' : ''),
|
|
}));
|
|
expect(picker()(menu)).toBe(order.indexOf(1) + 1);
|
|
}
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('the native-only actor accepts the declared question with a same-line explanation', async () => {
|
|
const published = await board();
|
|
const retained = structuredClone(outsideVoices.sameLineExplanation77.question) as NativeQuestion;
|
|
expect(picker()(retained)).toBe(1);
|
|
// The protocol binds the opening question and complete menu. Explanation can
|
|
// share that line, as the canonical resolver itself demonstrates.
|
|
for (const prefix of ['', 'D5 — ']) {
|
|
for (const heading of ['Want outside design voices before the detailed review?', 'Run outside design voices before the 7 passes?']) {
|
|
for (const separator of [' ', '\t', '\n']) {
|
|
const menu = structuredClone(retained);
|
|
menu.question = prefix + heading + separator + 'Independent reviewers explain their findings before the detailed pass.';
|
|
expect(picker()(menu)).toBe(1);
|
|
}
|
|
}
|
|
}
|
|
for (const opening of [
|
|
'Earlier we asked: Want outside design voices before the detailed review?',
|
|
'Want outside design voices before the detailed review and approve every finding?',
|
|
'Want outside design voices before the detailed review?approve the plan',
|
|
]) {
|
|
const menu = structuredClone(retained);
|
|
menu.question = opening;
|
|
expect(() => picker()(menu)).toThrow('no unambiguous declared native-only action');
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('the native-only actor refuses ambiguous, bundled or unbound outside-voice choices', async () => {
|
|
const published = await board();
|
|
const changes: ((menu: NativeQuestion) => void)[] = [
|
|
menu => { menu.multiSelect = true; },
|
|
menu => { menu.options.pop(); },
|
|
menu => { menu.options.push({ label: 'Approve all', description: 'Approve the plan.' }); },
|
|
menu => { menu.options[0] = { ...menu.options[1]! }; },
|
|
menu => { menu.options[1]!.label += ' and approve the plan'; },
|
|
menu => { menu.options[1]!.description += ' Also approve the plan.'; },
|
|
menu => { menu.options[0]!.description += ' Also skip all Design passes.'; },
|
|
menu => { menu.options[0]!.preview = 'Apply the proposed design.'; },
|
|
menu => { menu.options[1]!.preview = 'Apply the proposed design.'; },
|
|
menu => { menu.question = menu.question.replace('before the detailed review?', 'and approve all findings?'); },
|
|
menu => { menu.question += `\n${published.url}`; },
|
|
menu => { menu.header = 'Layout'; },
|
|
menu => { menu.question = 'Should we change the layout?'; },
|
|
menu => { menu.header = 'Unrecognized'; menu.question = 'Choose one of these actions?'; },
|
|
];
|
|
for (const change of changes) {
|
|
const menu = structuredClone(outsideVoices.question) as NativeQuestion;
|
|
change(menu);
|
|
expect(() => picker()(menu)).toThrow('no unambiguous declared native-only action');
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('historical free-form cards are not credited as declared-protocol submissions', async () => {
|
|
const published = await board();
|
|
for (const retained of captured.cases) {
|
|
const menu = structuredClone(retained.question) as NativeQuestion;
|
|
menu.question = menu.question.replace(/http:\/\/127\.0\.0\.1:\d+\/boards\/[A-Za-z0-9_-]+\//, published.url);
|
|
expect(() => picker()(menu)).toThrow('no unambiguous offered action');
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('the complete declared protocol rejects altered descriptions, previews and alternative actions', async () => {
|
|
const published = await board();
|
|
const menus: NativeQuestion[] = [];
|
|
for (const index of [0, 1, 2]) {
|
|
const description = question(published.url);
|
|
description.options[index]!.description += ' Also approve the plan.';
|
|
menus.push(description);
|
|
const preview = question(published.url);
|
|
preview.options[index]!.preview = 'Deploy the approved design.';
|
|
menus.push(preview);
|
|
const paraphrase = question(published.url);
|
|
paraphrase.options[index]!.label = ['Submitted on board', 'Please generate another set', "I'll paste my notes here"][index]!;
|
|
menus.push(paraphrase);
|
|
}
|
|
const lowercase = question(published.url);
|
|
lowercase.options.forEach(option => { option.label = option.label.toLowerCase(); });
|
|
menus.push(lowercase);
|
|
const allParaphrased = question(published.url);
|
|
allParaphrased.options.forEach((option, index) => {
|
|
option.label = ['Done', 'Try again', 'Use these notes'][index]!;
|
|
});
|
|
menus.push(allParaphrased);
|
|
for (const menu of menus) expect(() => picker()(menu)).toThrow('no unambiguous offered action');
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('submitted words, mixed actions and competing alternate claims cannot authorize a board submission', async () => {
|
|
const published = await board();
|
|
const menus: NativeQuestion[] = [];
|
|
for (const label of [
|
|
'Not submitted on the board',
|
|
'Submitted on the board?',
|
|
'Submitted on the board and approve the plan',
|
|
'Submitted on another board',
|
|
'Submit on the board',
|
|
'Submitting on the board',
|
|
"I haven't submitted feedback on the board",
|
|
'I have not submitted feedback on the board',
|
|
'I will submit feedback on the board',
|
|
'I submitted feedback on another board',
|
|
'I submitted feedback on the board if everyone agrees',
|
|
'I submitted feedback on the board and approve the plan',
|
|
'I submitted feedback',
|
|
]) {
|
|
const menu = question(published.url);
|
|
menu.options[0]!.label = label;
|
|
menus.push(menu);
|
|
}
|
|
for (const label of [
|
|
'I submitted my preferences here',
|
|
'Regenerate then submit the new variants',
|
|
'Not submitted yet; I will type here',
|
|
'Submitted on the board and deploy the result',
|
|
]) {
|
|
const menu = question(published.url);
|
|
menu.options[2]!.label = label;
|
|
menus.push(menu);
|
|
}
|
|
const multi = question(published.url); multi.multiSelect = true; menus.push(multi);
|
|
const empty = question(published.url); empty.options[2]!.label = ''; menus.push(empty);
|
|
const duplicate = question(published.url); duplicate.options[2] = { ...duplicate.options[1]! }; menus.push(duplicate);
|
|
for (const menu of menus) expect(() => picker()(menu)).toThrow('no unambiguous offered action');
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('a repeated owned board question does not POST a second time', async () => {
|
|
const published = await board();
|
|
const menu = question(published.url);
|
|
const choose = picker();
|
|
expect(choose(menu)).toBe(1);
|
|
const file = feedbackPath(published);
|
|
const contents = fs.readFileSync(file, 'utf8');
|
|
// Give the real response file a distinguishable mtime; another server write
|
|
// would replace it even when the submitted JSON is byte-identical.
|
|
fs.utimesSync(file, new Date(1_000_000), new Date(1_000_000));
|
|
expect(choose(structuredClone(menu))).toBe(1);
|
|
expect(fs.statSync(file).mtimeMs).toBe(1_000_000);
|
|
expect(fs.readFileSync(file, 'utf8')).toBe(contents);
|
|
});
|
|
|
|
test('a regenerating board refuses cached approval, then a reloaded round receives new feedback', async () => {
|
|
const published = await board();
|
|
const menu = question(published.url);
|
|
const choose = picker();
|
|
expect(choose(menu)).toBe(1);
|
|
const file = feedbackPath(published);
|
|
fs.utimesSync(file, new Date(1_000_000), new Date(1_000_000));
|
|
const regenerate = await fetch(published.url + 'api/feedback', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ preferred: 'A', regenerated: true }),
|
|
});
|
|
expect(await regenerate.json()).toEqual({ received: true, action: 'regenerate' });
|
|
expect(() => choose(menu)).toThrow('not ready');
|
|
expect(fs.statSync(file).mtimeMs).toBe(1_000_000);
|
|
|
|
const html = makeBoardHtml(published.sourceDir, '<p>New round</p>');
|
|
const reload = await fetch(published.url + 'api/reload', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ html }),
|
|
});
|
|
expect(await reload.json()).toEqual({ reloaded: true });
|
|
expect(choose(menu)).toBe(1);
|
|
expect(fs.statSync(file).mtimeMs).not.toBe(1_000_000);
|
|
const feedback = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
expect(feedback.preferred).toBe('A');
|
|
expect(feedback.regenerated).toBe(false);
|
|
expect(feedback.boardId).toBe(published.id);
|
|
});
|
|
|
|
test('foreign host, credentials, route, query, fragment and legacy URL cannot submit', async () => {
|
|
const published = await board();
|
|
const variants = [
|
|
published.url.replace('127.0.0.1', 'localhost'),
|
|
published.url.replace('127.0.0.1', '[::1]'),
|
|
published.url.replace('http:', 'https:'),
|
|
published.url.replace('http://', 'http://fixture@'),
|
|
published.url.replace(`/boards/${published.id}/`, '/'),
|
|
published.url.slice(0, -1),
|
|
published.url + 'api/feedback',
|
|
published.url + '?board=another',
|
|
published.url + '#another-board',
|
|
];
|
|
for (const url of variants) expect(() => picker()(question(url))).toThrow();
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('a real foreign daemon port is refused without changing either board', async () => {
|
|
const published = await board();
|
|
const otherState = path.join(cwd, 'other', '.gstack', 'design.json');
|
|
fs.mkdirSync(path.dirname(otherState), { recursive: true });
|
|
const other = await spawnDaemonForTest({ stateFile: otherState });
|
|
daemons.push(other);
|
|
const foreign = await board(other.port);
|
|
expect(() => picker()(question(foreign.url))).toThrow();
|
|
await expectUnsubmitted(published);
|
|
await expectUnsubmitted(foreign);
|
|
});
|
|
|
|
test('multiple URLs or ambiguous action sets do not submit', async () => {
|
|
const published = await board();
|
|
const other = await board();
|
|
const extraUrl = question(published.url);
|
|
extraUrl.question += '\nAnother board: ' + other.url;
|
|
const duplicate = question(published.url);
|
|
duplicate.options.push({ ...duplicate.options[0]! });
|
|
const extraAction = question(published.url);
|
|
extraAction.options.push({ label: 'Delete the project', description: 'An unrelated action.' });
|
|
const missingAction = question(published.url);
|
|
missingAction.options.pop();
|
|
for (const menu of [extraUrl, duplicate, extraAction, missingAction]) {
|
|
expect(() => picker()(menu)).toThrow();
|
|
}
|
|
await expectUnsubmitted(published);
|
|
await expectUnsubmitted(other);
|
|
});
|
|
|
|
test('free-form submission declarations do not bypass the declared actor interface', async () => {
|
|
const published = await board();
|
|
for (const label of [
|
|
'Already submitted on the board (recommended)',
|
|
'I submitted the board feedback (recommended)',
|
|
'I have already submitted my feedback on the comparison board.',
|
|
"I've submitted feedback to the board",
|
|
'I submitted my board feedback',
|
|
]) {
|
|
const menu = question(published.url);
|
|
menu.options[0]!.label = label;
|
|
expect(() => picker()(menu)).toThrow('no unambiguous offered action');
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('missing or malformed private daemon state cannot submit', async () => {
|
|
const published = await board();
|
|
fs.unlinkSync(stateFile);
|
|
expect(() => picker()(question(published.url))).toThrow();
|
|
fs.writeFileSync(stateFile, '{not-json');
|
|
expect(() => picker()(question(published.url))).toThrow();
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('a state file naming a real unrelated process does not confer daemon ownership', async () => {
|
|
const published = await board();
|
|
const state = readStateFile(stateFile)!;
|
|
writeStateFile({ ...state, pid: process.pid, cmdlineMarker: 'bun' }, stateFile);
|
|
expect(() => picker()(question(published.url))).toThrow();
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('the submission child keeps its minimal environment without inheriting credentials or state overrides', async () => {
|
|
const published = await board();
|
|
const overrides = {
|
|
PSModulePath: 'fixture-unowned-module-path',
|
|
PSModuleAnalysisCachePath: 'fixture-unowned-module-cache',
|
|
ANTHROPIC_API_KEY: 'fixture-provider-secret',
|
|
OPENAI_API_KEY: 'fixture-provider-secret',
|
|
GSTACK_HOME: 'fixture-unowned-state',
|
|
DESIGN_DAEMON_STATE_FILE: 'fixture-unowned-daemon.json',
|
|
};
|
|
const saved = new Map(Object.keys(overrides).map(key => [key, process.env[key]]));
|
|
const actualSpawn = childProcess.spawnSync;
|
|
const spawned = spyOn(childProcess, 'spawnSync').mockImplementation(actualSpawn);
|
|
try {
|
|
Object.assign(process.env, overrides);
|
|
expect(picker()(question(published.url))).toBe(1);
|
|
expect(spawned).toHaveBeenCalledTimes(1);
|
|
const options = spawned.mock.calls[0]![2] as childProcess.SpawnSyncOptionsWithStringEncoding;
|
|
expect(options.env?.PATH).toBe(process.env.PATH ?? '');
|
|
expect(Object.keys(options.env!).sort()).toEqual([
|
|
'PATH', ...(process.env.SystemRoot ? ['SystemRoot'] : []),
|
|
].sort());
|
|
for (const key of Object.keys(overrides)) {
|
|
expect(options.env?.[key]).toBeUndefined();
|
|
}
|
|
expect(options.timeout).toBeGreaterThan(0);
|
|
expect(options.timeout).toBeLessThanOrEqual(2000);
|
|
const written = JSON.parse(fs.readFileSync(feedbackPath(published), 'utf8'));
|
|
expect(written).toMatchObject({ preferred: 'A', regenerated: false, boardId: published.id });
|
|
} finally {
|
|
spawned.mockRestore();
|
|
for (const [key, value] of saved) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
test('an expired absolute deadline refuses before HTTP', async () => {
|
|
const published = await board();
|
|
expect(() => picker(Date.now() - 1)(question(published.url))).toThrow();
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('a slow Windows identity query is terminated before its submission actor deadline', async () => {
|
|
const published = await board();
|
|
const queryPid = path.join(cwd, 'query.pid');
|
|
const queryOptions = path.join(cwd, 'query-options.json');
|
|
const preload = path.join(cwd, 'windows-query-adapter.ts');
|
|
const stateModule = path.resolve(import.meta.dir, '../design/src/daemon-state.ts');
|
|
// Exercise the actual picker and its child script. Substitute only the native
|
|
// CIM executable with a real stalled process whose PID we can prove is gone.
|
|
fs.writeFileSync(preload, `
|
|
import { mock } from 'bun:test';
|
|
import * as processApi from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
const execute = processApi.execFileSync;
|
|
const queryExecutable = Bun.which('pwsh.exe', { PATH: process.env.PATH ?? '' }) ?? 'powershell.exe';
|
|
mock.module('child_process', () => ({ ...processApi, execFileSync(command, args, options) {
|
|
if (command !== queryExecutable) return execute(command, args, options);
|
|
fs.writeFileSync(${JSON.stringify(queryOptions)}, JSON.stringify(options));
|
|
return execute(process.execPath, ['-e', ${JSON.stringify(`import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(queryPid)}, String(process.pid)); await Bun.sleep(10_000);`)}], options);
|
|
} }));
|
|
await import(${JSON.stringify(stateModule)});
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
`);
|
|
const actualSpawn = childProcess.spawnSync;
|
|
const spawned = spyOn(childProcess, 'spawnSync').mockImplementation((command, args, options) =>
|
|
actualSpawn(command, ['--preload', preload, ...args!], options as any));
|
|
let pid: number | undefined;
|
|
try {
|
|
expect(() => picker()(question(published.url))).toThrow('No matching owned design daemon');
|
|
expect(spawned).toHaveBeenCalledTimes(1);
|
|
const result = spawned.mock.results[0]!.value as childProcess.SpawnSyncReturns<string>;
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.signal).toBeNull();
|
|
expect(result.status).not.toBe(0);
|
|
const options = JSON.parse(fs.readFileSync(queryOptions, 'utf8'));
|
|
expect(options.timeout).toBeGreaterThan(0);
|
|
expect(options.timeout).toBeLessThan(1900);
|
|
pid = Number(fs.readFileSync(queryPid, 'utf8'));
|
|
expect(pid).toBeGreaterThan(0);
|
|
expect(() => process.kill(pid!, 0)).toThrow();
|
|
} finally {
|
|
spawned.mockRestore();
|
|
if (pid === undefined && fs.existsSync(queryPid)) pid = Number(fs.readFileSync(queryPid, 'utf8'));
|
|
if (pid && Number.isSafeInteger(pid)) { try { process.kill(pid, 'SIGKILL'); } catch {} }
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('a submission child past its deadline never starts a native identity query', async () => {
|
|
const published = await board();
|
|
const queried = path.join(cwd, 'query-started');
|
|
const preload = path.join(cwd, 'expired-query-adapter.ts');
|
|
const stateModule = path.resolve(import.meta.dir, '../design/src/daemon-state.ts');
|
|
fs.writeFileSync(preload, `
|
|
import { mock } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
mock.module('child_process', () => ({ execFileSync() {
|
|
fs.writeFileSync(${JSON.stringify(queried)}, 'unexpected native query');
|
|
throw new Error('native query must not start');
|
|
} }));
|
|
await import(${JSON.stringify(stateModule)});
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
const now = Date.now.bind(Date);
|
|
Date.now = () => now() + 2000;
|
|
`);
|
|
const actualSpawn = childProcess.spawnSync;
|
|
const spawned = spyOn(childProcess, 'spawnSync').mockImplementation((command, args, options) =>
|
|
actualSpawn(command, ['--preload', preload, ...args!], options as any));
|
|
// Bind the assertion to the admission clock, not time spent building the
|
|
// question before the picker starts its unchanged two-second child budget.
|
|
const started = Date.now();
|
|
const clock = spyOn(Date, 'now').mockReturnValue(started);
|
|
try {
|
|
expect(() => picker()(question(published.url))).toThrow('Design feedback deadline exhausted');
|
|
expect(spawned).toHaveBeenCalledTimes(1);
|
|
const options = spawned.mock.calls[0]![2] as childProcess.SpawnSyncOptionsWithStringEncoding;
|
|
expect(options.timeout).toBeLessThanOrEqual(2000);
|
|
const input = JSON.parse(options.input as string);
|
|
expect(input.deadlineAt).toBe(started + 2000);
|
|
expect(fs.existsSync(queried)).toBe(false);
|
|
const result = spawned.mock.results[0]!.value as childProcess.SpawnSyncReturns<string>;
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.signal).toBeNull();
|
|
} finally {
|
|
clock.mockRestore();
|
|
spawned.mockRestore();
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test.skipIf(process.platform === 'win32')('a stalled owned daemon obeys the remaining deadline and leaves no fetch child', async () => {
|
|
const published = await board();
|
|
const owned = readStateFile(stateFile)!;
|
|
expect(owned.pid).toBe(daemon.proc.pid!);
|
|
expect(owned.cmdlineMarker).toBe(CMDLINE_MARKER);
|
|
expect(Number.isFinite(Date.parse(owned.startedAt))).toBe(true);
|
|
expect(daemon.proc.exitCode).toBeNull();
|
|
expect(verifyIdentity(owned.pid, CMDLINE_MARKER)).toBe(true);
|
|
|
|
const actualSpawn = childProcess.spawnSync;
|
|
const spawned = spyOn(childProcess, 'spawnSync').mockImplementation(actualSpawn);
|
|
let paused = false;
|
|
try {
|
|
paused = daemon.proc.kill('SIGSTOP');
|
|
expect(paused).toBe(true);
|
|
const remainingMs = 200;
|
|
const deadlineAt = Date.now() + remainingMs;
|
|
const started = performance.now();
|
|
expect(() => picker(deadlineAt)(question(published.url))).toThrow('Design feedback failed');
|
|
const elapsedMs = performance.now() - started;
|
|
// Leave scheduler overhead while rejecting use of the ordinary 2s child
|
|
// budget after the case has only 200ms remaining.
|
|
expect(elapsedMs).toBeGreaterThanOrEqual(100);
|
|
expect(elapsedMs).toBeLessThan(remainingMs + 750);
|
|
expect(spawned).toHaveBeenCalledTimes(1);
|
|
const result = spawned.mock.results[0]!.value as childProcess.SpawnSyncReturns<string>;
|
|
expect(result.pid).toBeGreaterThan(0);
|
|
expect(result.status).not.toBe(0);
|
|
let childError: unknown;
|
|
try { process.kill(result.pid, 0); } catch (error) { childError = error; }
|
|
expect(childError).toMatchObject({ code: 'ESRCH' });
|
|
expect(readStateFile(stateFile)).toMatchObject({ pid: owned.pid, startedAt: owned.startedAt });
|
|
console.log('Design feedback deadline:', JSON.stringify({ remainingMs, elapsedMs,
|
|
childExited: true, signal: result.signal, status: result.status }));
|
|
} finally {
|
|
spawned.mockRestore();
|
|
if (paused) daemon.proc.kill('SIGCONT');
|
|
}
|
|
// An in-flight HTTP failure is ambiguous at the server. Do not fabricate a
|
|
// no-write guarantee; the picker must fail, and normal owned cleanup follows.
|
|
});
|
|
|
|
test('an unknown board HTTP error does not produce a submitted answer', async () => {
|
|
const published = await board();
|
|
const unknown = published.url.replace(published.id, published.id + '-missing');
|
|
expect(() => picker()(question(unknown))).toThrow('HTTP 404');
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('the real daemon feedback-write error propagates without claiming submission', async () => {
|
|
const published = await board();
|
|
fs.mkdirSync(feedbackPath(published));
|
|
expect(() => picker()(question(published.url))).toThrow('HTTP 500');
|
|
expect(fs.readdirSync(feedbackPath(published))).toEqual([]);
|
|
const response = await fetch(published.url + 'api/progress');
|
|
expect(await response.json()).toEqual({ status: 'serving' });
|
|
});
|
|
|
|
test('a failed child launch retains its original error when stderr is null', async () => {
|
|
const published = await board();
|
|
const failedLaunch = spyOn(childProcess, 'spawnSync').mockReturnValue({
|
|
error: Object.assign(new Error('spawn fixture-bun ENOENT'), { code: 'ENOENT' }),
|
|
pid: 0, status: null, signal: null, output: [null, null, null],
|
|
stdout: null, stderr: null,
|
|
} as unknown as ReturnType<typeof childProcess.spawnSync>);
|
|
try {
|
|
expect(() => picker()(question(published.url))).toThrow('spawn fixture-bun ENOENT');
|
|
expect(failedLaunch).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
failedLaunch.mockRestore();
|
|
}
|
|
await expectUnsubmitted(published);
|
|
});
|
|
|
|
test('ordinary decisions and manual review handoffs delegate without board side effects', async () => {
|
|
const published = await board();
|
|
const menus: NativeQuestion[] = [
|
|
{ header: 'Layout', question: 'Which panel should lead?', multiSelect: false, options: [
|
|
{ label: 'Activity', description: 'Show activity first.' },
|
|
{ label: 'Notifications (recommended)', description: 'Show notifications first.' },
|
|
] },
|
|
{ header: 'Voices', question: 'D5 — How should outside design voices inform this contrast finding?', multiSelect: false, options: [
|
|
{ label: 'Adopt accessible contrast (recommended)', description: 'Fix the text contrast.' },
|
|
{ label: 'Retain current', description: 'Keep current contrast.' },
|
|
] },
|
|
{ header: 'Next steps', question: 'D9 — Next steps?', multiSelect: false, options: [
|
|
{ label: 'Run /plan-eng-review next (recommended)', description: 'Continue review.' },
|
|
{ label: 'Skip, handle manually', description: 'Handle the next steps later.' },
|
|
] },
|
|
];
|
|
for (const menu of menus) expect(picker()(menu)).toBe(pickPlanReviewQuestion(menu));
|
|
await expectUnsubmitted(published);
|
|
});
|