mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-28 07:32:14 +02:00
* perf: remove repeated test work and preserve AUQ execution budgets * fix: validate native evaluation fixture evidence at its actual boundaries * fix: clarify deployment approval and recovery state transitions * chore: document coverage and release v1.90.2.0 * test: preserve Windows scheduling and native no-change consent * test: recognize verified reads through fixture symlinks * test: isolate alias-name installation from runtime assets
549 lines
20 KiB
TypeScript
549 lines
20 KiB
TypeScript
import { 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 { pathToFileURL } from 'node:url';
|
||
import {
|
||
capturePlanCountQuestion,
|
||
planCountQuestionInput,
|
||
} from './helpers/claude-pty-runner';
|
||
import type { NativePlanQuestionCall } from './helpers/plan-count-transcript';
|
||
import {
|
||
nextCeoModeNavigation,
|
||
nextCeoPostureContinuation,
|
||
hasNativePostAnswerCeoPosture,
|
||
} from './helpers/ceo-mode-option';
|
||
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
|
||
|
||
const designOutsideQuestions = [
|
||
{
|
||
"question": "D3 (Step 0D) — I've rated this plan 5/10 on design completeness. The three biggest gaps are: (1) the 5 identified implementation gaps describe the problem but not the solution, (2) no explicit state coverage table, (3) no user journey emotional arc. I'll skip mockups and review all 7 dimensions as you requested. Any specific areas to prioritize, or cover all 7 equally? <gstack-qid:plan-design-focus>",
|
||
"header": "Focus areas",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"label": "Cover all 7 equally (Recommended)",
|
||
"description": "Standard review: all 7 design dimensions get full treatment. Takes longer but produces a complete plan."
|
||
},
|
||
{
|
||
"label": "Focus on the 5 identified gaps first",
|
||
"description": "Prioritize Pass 5 (Design System Alignment) to close the gap descriptions into actionable specs, then cover remaining passes more quickly."
|
||
},
|
||
{
|
||
"label": "Prioritize accessibility and states",
|
||
"description": "Focus on Pass 2 (Interaction States) and Pass 6 (Responsive/A11y), since the form has sensitive UX requirements (ARIA, contrast, keyboard)."
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"question": "D4 — Want outside design voices before the detailed review? Codex evaluates against OpenAI's design hard rules + litmus checks; a Claude subagent does an independent completeness review. (Requires Codex CLI to be installed.) <gstack-qid:outside-voices-design>",
|
||
"header": "Outside voices",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"label": "Yes, run outside design voices",
|
||
"description": "Launches Codex design critique + Claude subagent completeness review in parallel before the 7 passes. Adds 1–2 minutes."
|
||
},
|
||
{
|
||
"label": "No, proceed without (Recommended)",
|
||
"description": "Skip outside voices and go straight to the 7 review passes. Faster; sufficient for most plans."
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
const questions = [
|
||
{
|
||
header: 'Routing rules',
|
||
question: 'Add skill routing rules? <gstack-qid:routing-injection>',
|
||
options: [{ label: 'Enable' }, { label: 'Skip' }],
|
||
},
|
||
{
|
||
header: 'Cross-project',
|
||
question: 'Enable cross-project learnings?',
|
||
options: [{ label: 'Enable' }, { label: 'Skip' }],
|
||
},
|
||
];
|
||
function menu(index: number) {
|
||
return (
|
||
'← ☐ Routing rules ☐ Cross-project ✔ Submit →\n│ ' +
|
||
questions[index]!.question +
|
||
'\n❯1.Enable\n2.Skip\n3.Type something.\n4.Chat about this\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel\n'
|
||
);
|
||
}
|
||
const pending = (): NativePlanQuestionCall => ({
|
||
sessionId: 'native',
|
||
toolUseId: 'packet',
|
||
answered: false,
|
||
questions,
|
||
});
|
||
|
||
describe('native AUQ accepts one action per displayed question', () => {
|
||
test('mode permission history releases a fresh identical viewport grant', () => {
|
||
const seen = new Set<string>();
|
||
const permission = 'Do you want to create PLAN.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend\n';
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, '')).toEqual({
|
||
kind: 'permission', input: '1\r',
|
||
});
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, '').kind).toBe('wait');
|
||
const completed = '⎿ Wrote1line\n';
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, completed)).toEqual({
|
||
kind: 'permission', input: '1\r',
|
||
});
|
||
expect(nextCeoModeNavigation(permission, 'HOLD SCOPE', seen, undefined, completed).kind).toBe('wait');
|
||
});
|
||
test('each native tab has separate UI identity, while retaining one call identity', () => {
|
||
const seen = new Set<string>();
|
||
const first = capturePlanCountQuestion(menu(0), seen, 0, true, pending())!;
|
||
const second = capturePlanCountQuestion(menu(1), seen, 1, true, pending())!;
|
||
expect(first.nativeQuestionIndex).toBe(0);
|
||
expect(second.nativeQuestionIndex).toBe(1);
|
||
expect(first.signature).not.toBe(second.signature);
|
||
expect(first.nativeCall!.toolUseId).toBe(second.nativeCall!.toolUseId);
|
||
expect(planCountQuestionInput(menu(0), first, 2)).toBe('2');
|
||
expect(planCountQuestionInput(menu(1), second, 1)).toBe('1');
|
||
expect(
|
||
capturePlanCountQuestion(menu(0), seen, 2, true, pending()),
|
||
).toBeNull();
|
||
expect(
|
||
capturePlanCountQuestion(menu(1), seen, 3, true, pending()),
|
||
).toBeNull();
|
||
});
|
||
test('a delayed native record binds an already answered UI tab without another input', () => {
|
||
const seen = new Set<string>();
|
||
const ui = capturePlanCountQuestion(menu(0), seen, 0, true)!;
|
||
expect(ui.nativeCall).toBeUndefined();
|
||
expect(planCountQuestionInput(menu(0), ui, 1)).toBe('1');
|
||
expect(
|
||
capturePlanCountQuestion(menu(0), seen, 1, true, pending()),
|
||
).toBeNull();
|
||
expect(
|
||
capturePlanCountQuestion(menu(1), seen, 2, true, pending())!
|
||
.nativeQuestionIndex,
|
||
).toBe(1);
|
||
});
|
||
test('shared headers or options cannot borrow an unrelated native question', () => {
|
||
const foreign = menu(0).replace(
|
||
questions[0]!.question,
|
||
'Which release should we inspect?',
|
||
);
|
||
const fp = capturePlanCountQuestion(
|
||
foreign,
|
||
new Set(),
|
||
0,
|
||
true,
|
||
pending(),
|
||
)!;
|
||
expect(fp.nativeCall).toBeUndefined();
|
||
const prose = 'Which release?\n❯1.First\n2.Second\n';
|
||
const plain = capturePlanCountQuestion(prose, new Set(), 0, true)!;
|
||
expect(planCountQuestionInput(prose, plain, 2)).toBe('2\r');
|
||
});
|
||
test('known multi-select toggles once before its separate Submit control', () => {
|
||
const fp = capturePlanCountQuestion(
|
||
menu(0),
|
||
new Set(),
|
||
0,
|
||
true,
|
||
pending(),
|
||
)!;
|
||
fp.nativeCall = {
|
||
...fp.nativeCall!,
|
||
questions: fp.nativeCall!.questions.map((q) => ({
|
||
...q,
|
||
multiSelect: true,
|
||
})),
|
||
};
|
||
expect(planCountQuestionInput(menu(0), fp, 1)).toBe('1');
|
||
});
|
||
test('an incomplete checkbox panel without its submit control retains the legacy fallback', () => {
|
||
for (const mark of [' ', '✓']) {
|
||
const screen = menu(0)
|
||
.replace('1.Enable', `1.[${mark}] Enable`)
|
||
.replace('2.Skip', '2.[ ] Skip');
|
||
const fp = capturePlanCountQuestion(screen, new Set(), 0, true)!;
|
||
expect(fp.nativeCall).toBeUndefined();
|
||
expect(planCountQuestionInput(screen, fp, 1)).toBe('1\r');
|
||
}
|
||
});
|
||
test('CEO mode and its next native question accept one digit; permission stays separate', () => {
|
||
const mode = {
|
||
header: 'Review mode',
|
||
question: 'Which review posture? <gstack-qid:plan-ceo-mode>',
|
||
options: [{ label: 'HOLD SCOPE' }, { label: 'SCOPE EXPANSION' }],
|
||
};
|
||
const call = { ...pending(), questions: [mode] };
|
||
const screen =
|
||
'☐ Review mode\n' +
|
||
mode.question +
|
||
'\n❯1.HOLD SCOPE\n2.SCOPE EXPANSION\nEnter to select · ↑/↓ to navigate · Esc to cancel\n';
|
||
const seen = new Set<string>();
|
||
const action = nextCeoModeNavigation(screen, 'SCOPE EXPANSION', seen, call);
|
||
expect(action.kind).toBe('mode');
|
||
if (action.kind !== 'mode') throw Error('Expected native mode');
|
||
expect(planCountQuestionInput(screen, action.question, action.index)).toBe(
|
||
'2',
|
||
);
|
||
expect(
|
||
nextCeoModeNavigation(screen, 'SCOPE EXPANSION', seen, call).kind,
|
||
).toBe('wait');
|
||
const selected = {
|
||
...call,
|
||
answered: true,
|
||
answeredAt: '2026-09-08T20:00:01Z',
|
||
answers: { [mode.question]: 'SCOPE EXPANSION' },
|
||
};
|
||
const next = {
|
||
...pending(),
|
||
toolUseId: 'next',
|
||
questions: [
|
||
{
|
||
header: 'Expansion',
|
||
question: 'Which idea would create 10x delight?',
|
||
options: [{ label: 'First idea' }, { label: 'Second idea' }],
|
||
},
|
||
],
|
||
};
|
||
const transcript = {
|
||
status: 'ready' as const,
|
||
calls: [selected, next],
|
||
assistantMessages: [],
|
||
};
|
||
const nextScreen =
|
||
'☐ Expansion\n' +
|
||
next.questions[0]!.question +
|
||
'\n❯1.First idea\n2.Second idea\nEnter to select · ↑/↓ to navigate · Esc to cancel\n';
|
||
expect(
|
||
hasNativePostAnswerCeoPosture(
|
||
transcript,
|
||
'SCOPE EXPANSION',
|
||
/delight/,
|
||
Date.parse('2026-09-08T20:00:00Z'),
|
||
),
|
||
).toBe(false);
|
||
expect(
|
||
nextCeoPostureContinuation(
|
||
nextScreen,
|
||
transcript,
|
||
'SCOPE EXPANSION',
|
||
Date.parse('2026-09-08T20:00:00Z'),
|
||
new Set(),
|
||
false,
|
||
),
|
||
).toBe('question');
|
||
const fp = capturePlanCountQuestion(nextScreen, new Set(), 0, false, next)!;
|
||
expect(planCountQuestionInput(nextScreen, fp, 1)).toBe('1');
|
||
const permission =
|
||
'Do you want to create PLAN.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend\n';
|
||
const grant = nextCeoModeNavigation(
|
||
permission,
|
||
'SCOPE EXPANSION',
|
||
new Set(),
|
||
next,
|
||
);
|
||
expect(grant).toEqual({ kind: 'permission', input: '1\r' });
|
||
});
|
||
test('autoplan routing uses the native shortcut with early or delayed metadata, while prose is unchanged', () => {
|
||
const q = {
|
||
header: 'Routing rules',
|
||
question:
|
||
"gstack works best when your project's CLAUDE.md includes skill routing rules. Add them? <gstack-qid:routing-injection>",
|
||
options: [
|
||
{ label: 'Add routing rules (Recommended)' },
|
||
{ label: 'Skip — manual invocation' },
|
||
],
|
||
};
|
||
const native = { ...pending(), questions: [q] };
|
||
const screen =
|
||
'☐ Routing rules\n' +
|
||
q.question +
|
||
'\n❯1.Add routing rules (Recommended)\n2.Skip — manual invocation\nEnter to select · ↑/↓ to navigate · Esc to cancel\n';
|
||
expect(autoplanRoutingSetupInput(screen, new Set(), native)).toBe('1');
|
||
expect(autoplanRoutingSetupInput(screen, new Set())).toBe('1');
|
||
expect(
|
||
autoplanRoutingSetupInput(
|
||
screen.replace(
|
||
'Enter to select · ↑/↓ to navigate · Esc to cancel\n',
|
||
'',
|
||
),
|
||
new Set(),
|
||
),
|
||
).toBe('1\r');
|
||
expect(
|
||
autoplanRoutingSetupInput(
|
||
screen.replace(
|
||
q.question,
|
||
'Should the app change its product routing?',
|
||
),
|
||
new Set(),
|
||
native,
|
||
),
|
||
).toBeNull();
|
||
});
|
||
|
||
test.skipIf(process.platform === 'win32')(
|
||
'real PTY completes two and four tabs once without skipping, overshooting or queuing Enter',
|
||
async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'native-auq-input-'));
|
||
const fake = path.join(dir, 'fake-claude');
|
||
const worker = path.join(dir, 'worker.ts');
|
||
const results = path.join(dir, 'results.json');
|
||
const cases = [
|
||
{ name: 'two-tabs', count: 2, late: false, permission: true },
|
||
{
|
||
name: 'four-tabs',
|
||
count: 4,
|
||
late: false,
|
||
permission: false,
|
||
long: true,
|
||
},
|
||
{ name: 'late-packet', count: 2, late: true, permission: false },
|
||
{ name: 'design-outside-tab', count: 2, late: false, permission: false, designQuestions: designOutsideQuestions },
|
||
{ name: 'design-outside-late', count: 2, late: true, permission: false, designQuestions: designOutsideQuestions },
|
||
].map((item) => ({
|
||
...item,
|
||
record: path.join(dir, item.name + '.jsonl'),
|
||
}));
|
||
fs.writeFileSync(
|
||
fake,
|
||
`#!${process.execPath}\n` +
|
||
String.raw`
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
const item = JSON.parse(process.env.NATIVE_INPUT_CASE);
|
||
const record = (event) =>
|
||
fs.appendFileSync(item.record, JSON.stringify(event) + '\n');
|
||
const sessionId = 'native-input-' + process.pid;
|
||
const project = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', sessionId);
|
||
fs.mkdirSync(project, { recursive: true });
|
||
const native = (role, content, extra = {}) =>
|
||
fs.appendFileSync(
|
||
path.join(project, sessionId + '.jsonl'),
|
||
JSON.stringify({
|
||
cwd: process.cwd(),
|
||
sessionId,
|
||
isSidechain: false,
|
||
message: { role, content },
|
||
...extra,
|
||
}) + '\n',
|
||
);
|
||
native('assistant', [{ type: 'text', text: 'Fixture started.' }]);
|
||
record({ type: 'startup', pid: process.pid, cwd: process.cwd() });
|
||
const questions = item.designQuestions ?? Array.from({ length: item.count }, (_, i) => ({
|
||
header: 'Decision ' + i,
|
||
question:
|
||
(item.long && i === 0
|
||
? 'Additional concrete context for this decision. '.repeat(140)
|
||
: '') +
|
||
'Which implementation fixes gap ' +
|
||
i +
|
||
'? <gstack-qid:input-gap-' +
|
||
i +
|
||
'>',
|
||
options: [{ label: 'First remedy' }, { label: 'Second remedy' }],
|
||
}));
|
||
const ask = () =>
|
||
native('assistant', [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'packet',
|
||
name: 'AskUserQuestion',
|
||
input: { questions },
|
||
},
|
||
]);
|
||
let started = false,
|
||
permission = item.permission,
|
||
index = 0,
|
||
answers = {},
|
||
done = false;
|
||
const render = () => {
|
||
let screen = '';
|
||
if (permission)
|
||
screen =
|
||
'Do you want to create PLAN.md?\n❯1.Yes\n2.Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend\n';
|
||
else if (index < questions.length)
|
||
screen =
|
||
'← ' +
|
||
questions
|
||
.map((q) => (answers[q.question] ? '☒' : '☐') + ' ' + q.header)
|
||
.join(' ') +
|
||
' ✔ Submit →\n│ ' +
|
||
questions[index].question +
|
||
'\n' + questions[index].options.map((option, i) => (i === 0 ? '❯' : '') + (i + 1) + '.' + option.label).join('\n') +
|
||
'\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel\n';
|
||
else if (index === questions.length)
|
||
screen =
|
||
'← ' +
|
||
questions
|
||
.map((q) => (answers[q.question] ? '☒' : '☐') + ' ' + q.header)
|
||
.join(' ') +
|
||
' ✔ Submit →\nReview your answers\n' +
|
||
(Object.keys(answers).length < questions.length
|
||
? '⚠ You have not answered all questions\n'
|
||
: '') +
|
||
'Ready to submit your answers?\n❯1.Submit answers\n2.Cancel\n';
|
||
record({
|
||
type: 'screen',
|
||
index,
|
||
answers: Object.keys(answers),
|
||
blank: !screen,
|
||
});
|
||
process.stdout.write('\x1b[2J\x1b[H' + screen.replace(/\n/g, '\r\n'));
|
||
};
|
||
process.stdin.setRawMode?.(true);
|
||
process.stdin.on('data', (data) => {
|
||
const input = data.toString();
|
||
record({ type: 'input', input });
|
||
if (!started) {
|
||
started = true;
|
||
if (!item.late) ask();
|
||
render();
|
||
return;
|
||
}
|
||
if (done) {
|
||
record({ type: 'unexpected-after-completion', input });
|
||
return;
|
||
}
|
||
if (permission) {
|
||
if (input !== '1\r') throw Error('permission protocol changed');
|
||
permission = false;
|
||
render();
|
||
return;
|
||
}
|
||
const rendered = index;
|
||
if (input === '\x1b[Z') {
|
||
index = Math.max(0, index - 1);
|
||
render();
|
||
return;
|
||
}
|
||
if (rendered === questions.length) {
|
||
if (input !== '\r') throw Error('submit protocol changed');
|
||
if (item.late) ask();
|
||
native(
|
||
'user',
|
||
[{ type: 'tool_result', tool_use_id: 'packet', content: 'Answered.' }],
|
||
{ toolUseResult: { answers } },
|
||
);
|
||
record({ type: 'submitted', answers });
|
||
if (item.designQuestions && Object.values(answers)[1] === item.designQuestions[1].options[0].label) record({ type: 'outside-dispatched' });
|
||
done = true;
|
||
process.stdout.write('\x1b[2J\x1b[HGSTACK REVIEW REPORT\r\n');
|
||
return;
|
||
}
|
||
// CLI2.1.257's numeric shortcut and select:accept both call onChange,
|
||
// whose set-answer reducer increments the current question index. Events
|
||
// in one chunk retain the rendered question closure until the next paint.
|
||
for (const key of input) {
|
||
if (rendered >= questions.length) break;
|
||
if (/[1-2]/.test(key) || key === '\r') {
|
||
answers[questions[rendered].question] =
|
||
questions[rendered].options[key === '2' ? 1 : 0].label;
|
||
index++;
|
||
}
|
||
}
|
||
render();
|
||
});
|
||
process.on('SIGINT', () => process.exit(0));
|
||
process.stdin.resume();
|
||
process.stdout.write('PTY_READY:' + item.record + '\x1b[2J\x1b[H');
|
||
`,
|
||
);
|
||
fs.chmodSync(fake, 0o755);
|
||
fs.writeFileSync(
|
||
worker,
|
||
`import {runPlanSkillCounting} from ${JSON.stringify(pathToFileURL(path.resolve(import.meta.dir, 'helpers/claude-pty-runner.ts')).href)};
|
||
import {pickDesignCountOutsideVoices} from ${JSON.stringify(pathToFileURL(path.resolve(import.meta.dir, 'helpers/design-count-outside.ts')).href)};
|
||
const cases=${JSON.stringify(cases)};
|
||
const results = await Promise.all(cases.map(async item => ({
|
||
name: item.name,
|
||
observation: await runPlanSkillCounting({
|
||
skillName: 'plan-eng-review',
|
||
slashCommand: '/plan-eng-review',
|
||
followUpPrompt: 'Review only this fixture.',
|
||
startupReadyMarker: 'PTY_READY:' + item.record,
|
||
isLastStep0AUQ: () => false,
|
||
isReviewAUQ: () => true,
|
||
firstAUQPick: item.designQuestions ? undefined : () => 2,
|
||
pickAUQ: item.designQuestions ? pickDesignCountOutsideVoices : undefined,
|
||
reviewCountCeiling: 8,
|
||
timeoutMs: 35000,
|
||
env: { NATIVE_INPUT_CASE: JSON.stringify(item) },
|
||
}),
|
||
})));
|
||
await Bun.write(${JSON.stringify(results)},JSON.stringify(results));`,
|
||
);
|
||
const child = Bun.spawn([process.execPath, worker], {
|
||
env: {
|
||
...process.env,
|
||
BROWSE_TERMINAL_BINARY: fake,
|
||
EVALS_HERMETIC: '1',
|
||
},
|
||
stdout: 'pipe',
|
||
stderr: 'pipe',
|
||
});
|
||
const timer = setTimeout(() => child.kill('SIGKILL'), 35000);
|
||
try {
|
||
const [code, out, err] = await Promise.all([
|
||
child.exited,
|
||
new Response(child.stdout).text(),
|
||
new Response(child.stderr).text(),
|
||
]);
|
||
expect(code, out + err).toBe(0);
|
||
const report = JSON.parse(fs.readFileSync(results, 'utf8'));
|
||
for (const item of cases) {
|
||
const row = report.find((r) => r.name === item.name);
|
||
expect(row.observation.outcome, JSON.stringify(row.observation)).toBe(
|
||
'completion_summary',
|
||
);
|
||
expect(row.observation.reviewCount).toBe(1);
|
||
expect(row.observation.transcript.calls).toHaveLength(1);
|
||
expect(
|
||
row.observation.transcript.calls[0].unansweredQuestionIndices,
|
||
).toEqual([]);
|
||
const events = fs
|
||
.readFileSync(item.record, 'utf8')
|
||
.trim()
|
||
.split('\n')
|
||
.map((line) => JSON.parse(line));
|
||
expect(
|
||
events.filter((e) => e.type === 'input').map((e) => e.input),
|
||
).toEqual([
|
||
'/plan-eng-review\r',
|
||
...(item.permission ? ['1\r'] : []),
|
||
...(item.designQuestions ? ['1', '2'] : ['2', ...Array(item.count - 1).fill('1')]),
|
||
'\r',
|
||
]);
|
||
expect(events.filter((e) => e.type === 'screen' && e.blank)).toEqual(
|
||
[],
|
||
);
|
||
expect(
|
||
events.filter((e) => e.type === 'unexpected-after-completion'),
|
||
).toEqual([]);
|
||
const submitted = events.filter((e) => e.type === 'submitted');
|
||
expect(submitted).toHaveLength(1);
|
||
expect(Object.values(submitted[0].answers)).toEqual(item.designQuestions
|
||
? [item.designQuestions[0].options[0].label, item.designQuestions[1].options[1].label]
|
||
: ['Second remedy', ...Array(item.count - 1).fill('First remedy')]);
|
||
expect(events.filter(e => e.type === 'outside-dispatched')).toEqual([]);
|
||
expect(() => process.kill(events[0].pid, 0)).toThrow();
|
||
expect(fs.existsSync(events[0].cwd)).toBe(false);
|
||
}
|
||
} finally {
|
||
clearTimeout(timer);
|
||
child.kill('SIGKILL');
|
||
for (const item of cases) {
|
||
if (!fs.existsSync(item.record)) continue;
|
||
const pid = JSON.parse(
|
||
fs.readFileSync(item.record, 'utf8').split('\n')[0]!,
|
||
).pid;
|
||
try {
|
||
process.kill(pid, 'SIGKILL');
|
||
} catch {}
|
||
}
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
40000,
|
||
);
|
||
});
|