Files
gstack/test/helpers/pty/fake-session.ts
garrytan 5df542dc3f refactor(pty): split claude-pty-runner.ts into test/helpers/pty/* behind a barrel
Pure move: every line of the former 5,047-line runner lands verbatim in one
module (four private helpers gain `export` for cross-module use):
binary, screen (absorbs test/helpers/pty-screen.ts, which now re-exports it),
launch, session (PtyDriver), judge, classify, auq, plan-native, boundaries,
runners/{observation,counting,floor}. claude-pty-runner.ts re-exports the
original public surface by name; pty/ modules import siblings directly.

Tests that read the runner's source text:
- rewritten as behavioral: the unit test's model-pin tripwire (fake CLI argv:
  fallback chain, --model before extraArgs, hermetic --strict-mcp-config),
  pty-skill-seeding-wiring (runners through the fake driver; launcher through
  a fake CLI reporting CLAUDE_CONFIG_DIR). The "three wrappers forward model"
  grep is replaced by the runners' fake-driver launch assertions.
- pty-screen-session / pty-screen-supervision: stop copying runner source;
  they mock.module the real pty/screen.ts (and the fixture cleanup) instead.
- re-pointed to the owning module (they execute a sliced runner body with
  injected boundaries; no seam exists for those boundaries yet):
  eng-seeded-completion-ai, plan-floor-permission, plan-create-prepublication,
  plan-count-completion; hermetic-wiring's source guard now reads pty/launch.ts
  and scans every pty/ module for raw process.env spreads.
- plan-count-timeout and pty-output-wake mock the viewport at pty/screen.ts.
2026-09-30 06:05:49 +00:00

151 lines
5.8 KiB
TypeScript

/**
* Test-only fake PTY session driver. Feeds scripted frames on an injectable
* clock through the runners' launch seam (`PtyDriver`), so the plan-skill
* runners can be exercised without a CLI, a PTY, a model or real timers.
*
* A frame is shown when its trigger fires: `onInput` (the runner sent a
* matching input) or `afterMs` of fake time since the previous frame. Shown
* frames accumulate in the raw/visible history; the latest one is the
* current viewport. Never import this from production helpers.
*/
import type { ClaudePtyOptions, ClaudePtySession } from './launch';
import type { PtyDriver } from './session';
export interface FakeSessionContext {
/** Options the runner passed to `driver.launch`. */
launch: ClaudePtyOptions;
/** Current fake wall clock (ms since epoch). */
now: number;
/** Isolated CLAUDE_CONFIG_DIR stand-in, or null. */
configDir: string | null;
sent: readonly string[];
}
export interface FakeFrame {
/** Viewport text shown by this frame; also appended to the session history. */
screen: string;
/** Show when the runner sends this exact input (string) or a matching one (RegExp). */
onInput?: string | RegExp;
/** Otherwise show after this much fake time since the previous frame. Default 0. */
afterMs?: number;
/** Side effect when shown, e.g. appending native transcript records. */
effect?: (ctx: FakeSessionContext) => void;
/** End the fake process with this exit code when shown. */
exit?: number;
}
export interface FakePtyScript {
frames: FakeFrame[];
/** Directory exposed as `session.hermeticConfigDir`. Default null. */
configDir?: string | null;
/** Fake wall-clock start. Default: the real clock when the driver is created. */
startAt?: number;
/** Extra session fields (e.g. pending hook files) merged into the fake session. */
session?: Partial<ClaudePtySession>;
}
export interface FakePtyDriver {
driver: PtyDriver;
/** Every input the runner sent, in order. */
sent: string[];
/** Every launch request, in order. */
launches: ClaudePtyOptions[];
/** Number of `close()` calls. */
closes(): number;
now(): number;
}
export function createFakePtyDriver(script: FakePtyScript): FakePtyDriver {
const startAt = script.startAt ?? Date.now();
const configDir = script.configDir ?? null;
const sent: string[] = [];
const launches: ClaudePtyOptions[] = [];
let now = startAt;
let closes = 0;
let next = 0;
let lastShownAt = startAt;
let history = '';
let screen = '';
let lastMark = 0;
let exitCode: number | null = null;
let launched: ClaudePtyOptions | undefined;
const context = (): FakeSessionContext => ({ launch: launched!, now, configDir, sent });
const show = (frame: FakeFrame) => {
next++;
lastShownAt = now;
screen = frame.screen;
history += (history ? '\n' : '') + frame.screen;
frame.effect?.(context());
if (frame.exit !== undefined) exitCode = frame.exit;
};
const dueAt = (frame: FakeFrame | undefined) =>
!launched || !frame || frame.onInput !== undefined || exitCode !== null ? Infinity : lastShownAt + (frame.afterMs ?? 0);
const showDue = () => { while (dueAt(script.frames[next]) <= now) show(script.frames[next]!); };
const advance = (ms: number) => {
const target = now + Math.max(0, ms);
while (dueAt(script.frames[next]) <= target) { now = Math.max(now, dueAt(script.frames[next])); show(script.frames[next]!); }
now = target;
};
const session: ClaudePtySession = {
send(data: string) {
if (exitCode !== null) return;
sent.push(data);
const frame = script.frames[next];
const trigger = frame?.onInput;
if (trigger !== undefined && (typeof trigger === 'string' ? trigger === data : trigger.test(data))) show(frame!);
showDue();
},
sendKey(key) {
const keys = { Enter: '\r', Up: '\x1b[A', Down: '\x1b[B', Esc: '\x1b', Tab: '\t', ShiftTab: '\x1b[Z', CtrlC: '\x03' };
session.send(keys[key]);
},
rawOutput: () => history,
visibleText: () => history,
currentScreen: async () => screen,
currentScreenFrame: async () => ({ text: screen, rawEnd: history.length, styledText: [] }),
mark: () => (lastMark = history.length),
visibleSince: (marker?: number) => history.slice(marker ?? lastMark),
async waitForOutput(since: number, timeoutMs: number) {
if (history.length > since || exitCode !== null) return;
const until = now + timeoutMs;
while (history.length <= since && exitCode === null && now < until) advance(Math.min(until, dueAt(script.frames[next])) - now);
},
async waitForAny(patterns, opts) {
const until = now + (opts?.timeoutMs ?? 60_000);
for (;;) {
if (exitCode !== null) throw new Error(`claude exited (code=${exitCode}) before any pattern matched.`);
const visible = opts?.since !== undefined ? history.slice(opts.since) : history;
for (const matched of patterns) {
const index = typeof matched === 'string' ? visible.indexOf(matched) : visible.search(matched);
if (index >= 0) return { matched, index };
}
if (now >= until) throw new Error(`Timed out after ${opts?.timeoutMs ?? 60_000}ms waiting for fake output`);
advance(Math.min(opts?.pollMs ?? 250, until - now));
}
},
async waitFor(pattern, opts) { await session.waitForAny([pattern], opts); },
pid: () => undefined,
exited: () => exitCode !== null,
exitCode: () => exitCode,
hermeticConfigDir: configDir,
async close() { closes++; },
...script.session,
};
const driver: PtyDriver = {
async launch(opts) {
launches.push(opts);
launched = opts;
lastShownAt = now;
showDue();
return session;
},
now: () => now,
monotonic: () => now - startAt,
async sleep(ms: number) { advance(ms); },
};
return { driver, sent, launches, closes: () => closes, now: () => now };
}