Files
gstack/test/pty-screen.test.ts

180 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { createPtyScreen } from './helpers/pty-screen';
import { capturePlanCountQuestion, createPlanCountPermissionGuard, isPermissionDialogVisible, matchesNativePlanQuestion, nativePlanCallFingerprint, stripAnsi } from './helpers/claude-pty-runner';
import { autoplanRoutingSetupInput } from './helpers/autoplan-setup-question';
import { createPlanCountSnapshotWriter } from './helpers/plan-count-artifacts';
import { pickCeoCompletionHandoff } from './helpers/ceo-completion-handoff';
import type { NativePlanQuestionCall } from './helpers/plan-count-transcript';
function seed(frame: { initial: string[]; cursor: { x: number; y: number } }): string {
return '\x1b[?1049h\x1b[2J' + frame.initial.map((line, i) => `\x1b[${i + 1};1H${line}\x1b[K`).join('') +
`\x1b[${frame.cursor.y + 1};${frame.cursor.x + 1}H`;
}
const fixture = (name: string) => JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/pty-screen', name + '.json'), 'utf8'));
describe('owned PTY viewport', () => {
test('preserves cursor positioning, carriage return, erase, and alternate-screen restoration', async () => {
const screen = await createPtyScreen(20, 4);
try {
screen.write('normal');
screen.write('\x1b[?1049h\x1b[2J\x1b[Habcdef\rX\x1b[4G!\x1b[K');
expect((await screen.read()).split('\n')[0]).toBe('Xbc!');
screen.write('\x1b[2;1Hsecond\x1b[2J\x1b[Hready');
expect(await screen.read()).toBe('ready\n\n\n');
screen.write('\x1b[?1049l');
expect((await screen.read()).split('\n')[0]).toBe('normal');
} finally { await screen.dispose(); }
});
test('uses the requested dimensions and excludes scrollback', async () => {
const screen = await createPtyScreen(12, 3);
try {
screen.write('old permission\r\n1\r\n2\r\n3\r\n4');
const current = await screen.read();
expect(current.split('\n')).toHaveLength(3);
expect(current).not.toContain('old permission');
expect(current).toBe('2\n3\n4');
} finally { await screen.dispose(); }
});
test('sessions isolate state and dispose flushes pending writes once without changing navigator', async () => {
const navigatorBefore = globalThis.navigator;
const a = await createPtyScreen(30, 4);
const b = await createPtyScreen(30, 4);
a.write('first'); b.write('second');
const closing = a.dispose();
await closing;
expect(await a.read()).toBe('first\n\n\n');
expect(await b.read()).toBe('second\n\n\n');
expect(a.dispose()).toBe(closing);
expect(() => a.write('late')).toThrow('disposed');
await b.dispose();
expect(globalThis.navigator).toBe(navigatorBefore);
});
test('retains a long genuine question when its options are visible and its header has scrolled away', async () => {
const screen = await createPtyScreen(120, 40);
try {
screen.write('☐ API consistency\r\n' + 'Existing context for this independent issue.\r\n'.repeat(45) +
'Which argument order should run_eval/run_batch use?\r\n❯1.Use the same argument order\r\n2.Keep the inconsistent order\r\n' +
'Enter to select · ↑/↓ to navigate · Esc to cancel');
const current = await screen.read();
expect(current).not.toContain('☐ API consistency');
const question = capturePlanCountQuestion(current, new Set(), 0, false);
expect(question).not.toBeNull();
expect(question?.options[0].label).toBe('Use the same argument order');
expect(question?.options).toHaveLength(2);
} finally { await screen.dispose(); }
});
test('clipped native handoff preserves its bound manual choice in either option order', async () => {
for (const contextLines of [36, 41]) for (const reverse of [false, true]) {
// The captured CEO handoff explains the required gate in this choice.
// Keep that native evidence even when the viewport clips its heading.
const options = [{ label: 'Run /plan-eng-review next',
description: 'Eng Review is the only required gate before shipping. Covers architecture, code quality, tests, and performance at the diff level. Run it now to clear the shipping gate.',
}, { label: "Done — I'll handle reviews manually" }];
if (reverse) options.reverse();
const call: NativePlanQuestionCall = { sessionId: 'long-handoff', toolUseId: 'pending', answered: false,
questions: [{ header: 'Next review',
question: 'CEO review is complete. Which next review should run? <gstack-qid:plan-ceo-next-steps>\n' +
Array.from({ length: contextLines }, (_, i) => `Review context line ${i + 1}: the existing requirements remain approved.`).join('\n'),
options }] };
const q = call.questions[0]!;
const screen = await createPtyScreen(120, 40);
try {
screen.write(`☐ ${q.header}\r\n${q.question.replace(/\n/g, '\r\n')}\r\n` +
`❯ 1. ${options[0]!.label}\r\n 2. ${options[1]!.label}\r\nEnter to select · ↑/↓ to navigate · Esc to cancel`);
const current = await screen.read();
expect(current).not.toContain('☐ Next review');
expect(current.includes('<gstack-qid:')).toBe(contextLines === 36);
expect(matchesNativePlanQuestion(current, call)).toBe(true);
const seen = new Set<string>();
const capture = capturePlanCountQuestion(current, seen, 0, false, call);
expect(capture?.nativeCall).toBe(call);
expect(pickCeoCompletionHandoff(nativePlanCallFingerprint(call, 0, false), capture!)).toBe(reverse ? 1 : 2);
expect(capturePlanCountQuestion(current, seen, 1, false, call)).toBeNull();
for (const unrelated of [
// Same choices are not identity, even with an intact native footer.
current.slice(current.indexOf('❯ 1.')),
current.replace('Review context line', 'Unrelated context line'),
'A different question with shared context?\n' + current,
current.replace('2. ' + options[1]!.label, '2. Approve a new implementation task'),
current.replace('↑/↓ to navigate', '↑/↓ to navigte'),
current + '\nDo you want to create another.md?\n❯ 1. Yes\n2. No\nEsc to cancel · Tab to amend',
]) {
expect(matchesNativePlanQuestion(unrelated, call)).toBe(false);
const other = capturePlanCountQuestion(unrelated, new Set(), 0, false, call);
expect(other?.nativeCall).toBeUndefined();
if (other) expect(pickCeoCompletionHandoff(nativePlanCallFingerprint(call, 0, false), other)).toBeNull();
}
expect(capturePlanCountQuestion(current, new Set(), 0, false, { ...call, failed: true })?.nativeCall).toBeUndefined();
} finally { await screen.dispose(); }
}
});
for (const chunkSize of [37, 4096]) {
test(`captured routing redraw recovers the intact manual option (${chunkSize}-character chunks)`, async () => {
const frame = fixture('autoplan');
const screen = await createPtyScreen(frame.cols, frame.rows);
try {
screen.write(seed(frame));
for (let i = 0; i < frame.update.length; i += chunkSize) screen.write(frame.update.slice(i, i + chunkSize));
const current = await screen.read();
expect(current.split('\n').map(line => line.trimEnd())).toEqual(frame.expected.map((line: string) => line.trimEnd()));
expect(current).toContain("2. No thanks, I'll invoke skills manually");
expect(stripAnsi(frame.update)).toContain("2. N thanks, I'll invokeskillsmanually");
expect(autoplanRoutingSetupInput(current, new Set())).toBe('1');
} finally { await screen.dispose(); }
});
}
test('historical file results release a new current permission but cannot activate stale scrollback', () => {
const menu = 'Do you want to create plan.md?\n❯1.Yes\n2.No\nEsc to cancel · Tab to amend';
const completed = menu + '\n⎿ Wrote 44 lines';
const guard = createPlanCountPermissionGuard();
expect(guard(menu, menu)).toBe('grant');
expect(guard(menu, menu)).toBe('handled');
expect(guard('⎿ Wrote 44 lines\n❯ ', completed)).toBeNull();
// The actual new menu has no old Write in its viewport. The separate
// history establishes a new completed-file epoch for this same target.
expect(guard(menu, completed + '\n' + menu)).toBe('grant');
expect(guard(menu, completed + '\n' + menu)).toBe('handled');
expect(guard('❯ ', completed + '\n' + menu)).toBeNull();
expect(guard(menu + '\n⎿ Wrote 1 line', completed + '\n' + menu + '\n⎿ Wrote 1 line')).toBe('handled');
});
test('captured successful Write removes the old permission; raw and viewport evidence stay separate', async () => {
const frame = fixture('design');
const screen = await createPtyScreen(frame.cols, frame.rows);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'screen-evidence-'));
try {
const raw = seed(frame) + frame.update;
screen.write(raw);
const active = await screen.read();
expect(active.split('\n').map(line => line.trimEnd())).toEqual(frame.expected.map((line: string) => line.trimEnd()));
expect(active).toContain('Do you want to create gstack-test-plan-design.md?');
expect(isPermissionDialogVisible(active)).toBe(true);
screen.write(frame.completed);
const current = await screen.read();
expect(current).toContain('Wrote 44 lines');
expect(current).not.toContain('Do you want');
expect(isPermissionDialogVisible(current)).toBe(false);
expect(capturePlanCountQuestion(current, new Set(), 0, false)).toBeNull();
const preservedRaw = raw + frame.completed;
expect(stripAnsi(preservedRaw)).toContain('Do you wat');
const native = { calls: [{ toolUseId: 'native-question', answered: true }] };
const save = createPlanCountSnapshotWriter({ EVALS_RUN_ID: 'screen-proof', GSTACK_EVAL_DIR: dir });
const saved = save({ skillName: 'plan-design-review', cwd: dir, claudeConfigDir: null,
raw: preservedRaw, visible: stripAnsi(preservedRaw), viewport: current, observation: { native } });
expect(saved.artifactError).toBeUndefined();
expect(fs.readFileSync(path.join(saved.artifactDir!, 'terminal.raw.log'), 'utf8')).toBe(preservedRaw);
expect(fs.readFileSync(path.join(saved.artifactDir!, 'terminal.screen.log'), 'utf8')).toBe(current);
expect(JSON.parse(fs.readFileSync(path.join(saved.artifactDir!, 'observation.json'), 'utf8')).native).toEqual(native);
} finally { await screen.dispose(); fs.rmSync(dir, { recursive: true, force: true }); }
});
});