Files
Codeman/test/codex-predictive-echo.test.ts
Codeman maintainer fa02bd4503 test(predictive-echo): E2E suite against a real codex TUI (Layer 5)
Out-of-process lab server (VITEST markers stripped so tmux/codex are real),
CODEMAN_INSTANCE=codexlab on port 3222, throwaway CODEX_HOME with a fake
key. Ten scenarios: bundle smoke, predict+converge typing, the #218 arrow
retest (submitted text exact), the #222 live picker, the #219 paste order,
the #220 wrap, the trust-modal ghost eliminator, the localEchoEnabled kill
switch, the end-to-end byte-identity trace (predictor active vs null), and
a display-delayed 300ms-RTT run pinning instant spans with exact pixel
geometry plus arrow-edit correctness under lag.

Live-TUI hardening learned the hard way: codex Ctrl+U kills only to line
start (End first), a fake-key submit leaves a Reconnecting loop that can
kill codex seconds later (retry-cancel + composer stability probe; the
submitting scenario runs after all composer-state ones), and typing must
wait for the predictWhen gate itself, not merely a rendered composer.
CI-excluded like the other Playwright suites; skips cleanly when codex is
not installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 05:02:14 +02:00

607 lines
24 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.
/**
* @fileoverview Predictive-echo E2E against a REAL codex 0.147 TUI (issues
* #218/#219/#220/#222 retest scenarios + the byte-identity and simulated-RTT
* pins). CI-EXCLUDED (needs chromium + the codex binary); a REQUIRED item of
* the release checklist.
*
* Unlike the other Playwright tests this one cannot use the in-process
* WebServer: under VITEST the mux layer is a pure in-memory mock and Session
* spawns an echo PTY, so no real codex would ever run. The lab server is a
* CHILD PROCESS with the VITEST markers stripped from its env, isolated via
* CODEMAN_INSTANCE=codexlab (own data dir under the per-test fixture HOME +
* own tmux socket) on port 3222 (3220/3221 are taken; see the port sweep in
* the plan). Codex runs against a throwaway CODEX_HOME with a fake API key
* (never leaves the box: the first request 401s, which is fine — every
* scenario here is about the composer, not completions).
*
* Run: npx vitest run --config config/vitest.config.ts test/codex-predictive-echo.test.ts
*/
import { execSync, spawn, type ChildProcess } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { userInfo } from 'node:os';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
const PORT = 3222;
const BASE_URL = `http://localhost:${PORT}`;
const INSTANCE = 'codexlab';
const TMUX = `tmux -L codeman-${INSTANCE}`;
const ROOT = resolve(import.meta.dirname, '..');
const CODEX_BIN_DIR = `${userInfo().homedir}/.local/bin`;
let server: ChildProcess | null = null;
let browser: Browser;
let context: BrowserContext;
let page: Page;
let codexHome: string;
let trustedWorkdir: string;
let untrustedWorkdir: string;
let sessionId: string;
const createdSessions: string[] = [];
function hasCodex(): boolean {
try {
execSync(`PATH="${CODEX_BIN_DIR}:$PATH" codex --version`, { stdio: 'pipe' });
return true;
} catch (e) {
console.error('[codex-e2e] codex unavailable:', (e as Error).message.slice(0, 300));
return false;
}
}
async function api(method: string, path: string, body?: unknown): Promise<any> {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
return res.json();
}
const paneByTail = new Map<string, string>();
/** Rendered pane text (tmux ground truth). Panes are found once by the
* workdir basename codex prints in its banner, then cached by pane id —
* the lab tmux socket is exclusive to this test. */
function capturePaneFor(workdir: string): string {
const tail = workdir.split('/').pop()!;
const panes = execSync(`${TMUX} list-panes -a -F '#{pane_id}'`, { encoding: 'utf8' }).trim().split('\n');
const capture = (p: string) => execSync(`${TMUX} capture-pane -p -t '${p}'`, { encoding: 'utf8' });
const cached = paneByTail.get(tail);
if (cached && panes.includes(cached)) return capture(cached);
for (const p of panes.filter(Boolean)) {
const text = capture(p);
if (text.includes(tail)) {
paneByTail.set(tail, p);
return text;
}
}
throw new Error(`no pane showing workdir ${tail}; panes: ${panes.join(', ')}`);
}
async function createCodexSession(workingDir: string): Promise<string> {
const created = await api('POST', '/api/sessions', {
mode: 'codex',
workingDir,
envOverrides: { CODEX_HOME: codexHome },
});
// Tolerate both the ApiResponse envelope and the legacy raw shape
const payload = created.data ?? created;
const id = payload.session?.id ?? payload.id ?? payload.sessionId;
expect(id, JSON.stringify(created).slice(0, 300)).toBeTruthy();
createdSessions.push(id);
return id;
}
/** Select the session in the UI and wait for the codex composer to render. */
async function openInBrowser(id: string): Promise<void> {
await page.evaluate((sid) => (window as any).app.selectSession(sid), id);
await page.waitForFunction(
() => {
const app = (window as any).app;
const buf = app.terminal?.buffer.active;
if (!buf) return false;
for (let y = 0; y < app.terminal.rows; y++) {
const t = buf.getLine(buf.baseY + y)?.translateToString(true) ?? '';
if (/^› /.test(t)) return true;
}
return false;
},
undefined,
{ timeout: 30000 }
);
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
// Wait for the exact typing precondition: the CURSOR parked on the composer
// row (the predictWhen gate itself), not merely a composer row existing —
// during the boot animation the cursor roams and predictions are suppressed.
await page.waitForFunction(
() => {
const w = window as any;
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
},
undefined,
{ timeout: 20000 }
);
await new Promise((r) => setTimeout(r, 300));
}
function predictState(): Promise<{ outstanding: number; confirmedTotal: number; droppedTotal: number }> {
return page.evaluate(() => (window as any).app._predictiveEcho.state);
}
function spanCount(): Promise<number> {
return page.evaluate(() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length);
}
/** Deterministic composer reset: codex's Ctrl+U kills only to LINE START, so
* End first; then a settle. Leaves the cursor parked on an empty composer. */
async function resetComposer(): Promise<void> {
// Codex can be mid-respawn after an exhausted 401 retry loop killed it:
// wait for a live composer (the gate itself) before touching the keyboard.
await page.waitForFunction(
() => {
const w = window as any;
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
},
undefined,
{ timeout: 25000 }
);
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
await page.keyboard.press('End');
await page.keyboard.press('Control+u');
await new Promise((r) => setTimeout(r, 400));
}
/** After a submit 401s, codex sits in a Reconnecting retry loop that can eat
* typed input; Esc interrupts it. Wait until the retry line is gone. */
async function cancelRetryLoop(): Promise<void> {
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
const pane = capturePaneFor(trustedWorkdir);
if (!/Reconnecting|esc to interrupt/.test(pane)) break;
await page.keyboard.press('Escape');
await new Promise((r) => setTimeout(r, 500));
}
// Settle to a live composer (codex may have died at 5/5 and respawned),
// then require it to STAY alive: the fake-key request can kill codex
// seconds later, so a single gate-true observation is not enough.
const stableDeadline = Date.now() + 30000;
for (;;) {
await resetComposer();
let stable = true;
for (let i = 0; i < 3; i++) {
await new Promise((r) => setTimeout(r, 1000));
const alive = await page.evaluate(() => {
const w = window as any;
return w.CodemanTerminalInput.isCodexComposerRow(w.app.terminal);
});
if (!alive) {
stable = false;
break;
}
}
if (stable || Date.now() > stableDeadline) return;
}
}
const CODEX_AVAILABLE = hasCodex();
const d = CODEX_AVAILABLE ? describe : describe.skip;
beforeAll(async () => {
if (!CODEX_AVAILABLE) return;
codexHome = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-'));
trustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-work-'));
untrustedWorkdir = mkdtempSync(resolve(ROOT, 'tmp', 'codexlab-untrusted-'));
mkdirSync(codexHome, { recursive: true });
writeFileSync(resolve(codexHome, 'auth.json'), JSON.stringify({ OPENAI_API_KEY: 'sk-test-123' }));
writeFileSync(resolve(codexHome, 'config.toml'), `[projects."${trustedWorkdir}"]\ntrust_level = "trusted"\n`);
// Child env: strip the vitest markers so the lab server runs REAL tmux/codex
const env: Record<string, string | undefined> = { ...process.env };
delete env.VITEST;
delete env.VITEST_MODE;
delete env.VITEST_POOL_ID;
delete env.VITEST_WORKER_ID;
delete env.NODE_ENV;
env.CODEMAN_INSTANCE = INSTANCE;
env.PATH = `${CODEX_BIN_DIR}:${env.PATH}`;
server = spawn('npx', ['tsx', 'src/index.ts', 'web', '--port', String(PORT)], {
cwd: ROOT,
env: env as NodeJS.ProcessEnv,
stdio: 'ignore',
detached: false,
});
// Wait for the lab server
const deadline = Date.now() + 30000;
for (;;) {
try {
const res = await fetch(`${BASE_URL}/api/status`);
if (res.ok) break;
} catch {
/* not up yet */
}
if (Date.now() > deadline) throw new Error('lab server did not start on :3222');
await new Promise((r) => setTimeout(r, 300));
}
browser = await chromium.launch({ headless: true });
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
await page.addInitScript(() => {
localStorage.setItem('codeman-app-settings', JSON.stringify({ localEchoEnabled: true }));
});
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10000 });
sessionId = await createCodexSession(trustedWorkdir);
await openInBrowser(sessionId);
}, 120_000);
afterAll(async () => {
for (const id of createdSessions) {
try {
await api('DELETE', `/api/sessions/${id}`);
} catch {
/* best effort */
}
}
await context?.close();
await browser?.close();
server?.kill('SIGTERM');
try {
execSync(`${TMUX} kill-server`, { stdio: 'ignore' });
} catch {
/* already gone */
}
}, 30_000);
d('codex predictive echo E2E (real codex TUI)', () => {
it('bundle smoke: both echo globals are defined', async () => {
const globals = await page.evaluate(() => ({
zerolag: typeof (window as any).LocalEchoOverlay,
predictive: typeof (window as any).PredictiveEchoOverlay,
instance: !!(window as any).app._predictiveEcho,
}));
expect(globals.zerolag).toBe('function');
expect(globals.predictive).toBe('function');
expect(globals.instance).toBe(true);
});
it('typing predicts every char and converges into the real echo', async () => {
await page.keyboard.type('hello', { delay: 30 });
// Keystrokes went through the predictor (a transient repaint may suppress
// one or two: that is designed graceful degradation, never a ghost)
await page.waitForFunction(() => {
const s = (window as any).app._predictiveEcho.state;
return s.confirmedTotal + s.outstanding >= 3;
});
// Convergence: spans gone, composer shows the text, ZERO mispredictions
await page.waitForFunction(
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0
);
const s = await predictState();
expect(s.confirmedTotal).toBeGreaterThanOrEqual(3);
expect(s.droppedTotal).toBe(0);
const deadline = Date.now() + 10000;
let pane = '';
while (Date.now() < deadline) {
pane = capturePaneFor(trustedWorkdir);
if (pane.includes('hello')) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(pane).toContain('hello');
}, 30_000);
it('#222: slash picker filters live while predictions confirm away', async () => {
await resetComposer();
await page.keyboard.type('/', { delay: 30 });
const deadline = Date.now() + 10000;
let pane = '';
while (Date.now() < deadline) {
pane = capturePaneFor(trustedWorkdir);
if (/\/model|\/skills|\/init/.test(pane)) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(pane).toMatch(/\/model|\/skills|\/init/);
await page.keyboard.type('mo', { delay: 40 });
await page.waitForFunction(
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0
);
const filtered = capturePaneFor(trustedWorkdir);
expect(filtered).toContain('/model');
await page.keyboard.press('Escape'); // close the picker
await page.keyboard.press('Control+u'); // deterministically empty the composer
await new Promise((r) => setTimeout(r, 300));
const s = await predictState();
expect(s.outstanding).toBe(0);
}, 30_000);
it('#219: typed text + paste land in order with nothing dropped', async () => {
await resetComposer();
const pidBefore = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim();
await page.evaluate(() => {
const app = (window as any).app;
(window as any).__dbg = [];
if (!(window as any).__origSend2) (window as any).__origSend2 = app._sendInputAsync.bind(app);
app._sendInputAsync = (sid: string, data: string, opts?: unknown) => {
(window as any).__dbg.push([sid.slice(0, 8), JSON.stringify(data)]);
return (window as any).__origSend2(sid, data, opts);
};
});
await page.keyboard.type('abc', { delay: 30 });
await page.evaluate(() => (window as any).app.terminal.paste('XYZ'));
const deadline = Date.now() + 15000;
let pane = '';
while (Date.now() < deadline) {
pane = capturePaneFor(trustedWorkdir);
if (pane.includes('abcXYZ')) break;
await new Promise((r) => setTimeout(r, 250));
}
if (!pane.includes('abcXYZ')) {
const pidAfter = execSync(`${TMUX} list-panes -a -F '#{pane_id} #{pane_pid}'`, { encoding: 'utf8' }).trim();
const dbg = await page.evaluate(() => (window as any).__dbg);
const row = await page.evaluate(() => {
const b = (window as any).app.terminal.buffer.active;
return b.getLine(b.baseY + b.cursorY)?.translateToString(true);
});
console.log(
'DBG219 pids-before:',
pidBefore,
'| pids-after:',
pidAfter,
'| sends:',
JSON.stringify(dbg),
'| app-cursor-row:',
JSON.stringify(row)
);
}
expect(pane).toContain('abcXYZ');
expect(await spanCount()).toBe(0); // paste classified 'clear'
await page.keyboard.press('Control+u'); // Ctrl+U: clear the composer line
}, 30_000);
it('#220: wrapped input renders exactly, no ghost glyphs past the edge', async () => {
await resetComposer();
const long = 'the quick brown fox jumps over the lazy dog and keeps running until the composer has to wrap';
await page.keyboard.type(long, { delay: 5 });
// Predictions on the first composer row confirm or drop; continuation
// rows are gate-suppressed. Everything converges:
await page.waitForFunction(
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
undefined,
{ timeout: 10000 }
);
const deadline = Date.now() + 10000;
let ok = false;
while (Date.now() < deadline) {
const pane = capturePaneFor(trustedWorkdir).replace(/\s+/g, ' ');
if (pane.includes('composer has to wrap')) {
ok = true;
break;
}
await new Promise((r) => setTimeout(r, 250));
}
expect(ok).toBe(true);
await page.keyboard.press('Control+u');
await new Promise((r) => setTimeout(r, 200));
}, 45_000);
it('modal ghost eliminator: zero spans while typing on the trust dialog', async () => {
const modalSession = await createCodexSession(untrustedWorkdir);
try {
await page.evaluate((sid) => (window as any).app.selectSession(sid), modalSession);
// Wait for the trust dialog (the pane may not exist for the first second)
const deadline = Date.now() + 25000;
let pane = '';
while (Date.now() < deadline) {
try {
pane = capturePaneFor(untrustedWorkdir);
if (pane.includes('Press enter to continue')) break;
} catch {
/* session still spawning */
}
await new Promise((r) => setTimeout(r, 300));
}
expect(pane).toContain('Press enter to continue');
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
await page.keyboard.type('x', { delay: 30 });
expect(await spanCount()).toBe(0); // gate rejected: no ghost on the modal
expect((await predictState()).outstanding).toBe(0);
} finally {
// Never strand later tests on the modal session
await api('DELETE', `/api/sessions/${modalSession}`);
await page.evaluate((sid) => (window as any).app.selectSession(sid), sessionId);
await page.waitForFunction(() => (window as any).app._localEchoPolicy === 'predict');
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
}
}, 45_000);
it('kill switch: localEchoEnabled OFF clears spans and typing still streams', async () => {
await page.evaluate(() => {
const app = (window as any).app;
const s = app.loadAppSettingsFromStorage();
s.localEchoEnabled = false;
app.saveAppSettingsToStorage(s);
app._updateLocalEchoState();
});
expect(await page.evaluate(() => (window as any).app._localEchoPolicy)).toBe('off');
expect(await spanCount()).toBe(0);
await page.locator('#terminalContainer').click({ position: { x: 200, y: 200 } });
await page.keyboard.press('End');
await page.keyboard.press('Control+u');
await page.keyboard.type('still-live', { delay: 20 });
const deadline = Date.now() + 10000;
let pane = '';
while (Date.now() < deadline) {
pane = capturePaneFor(trustedWorkdir);
if (pane.includes('still-live')) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(pane).toContain('still-live'); // 1.12.2 behavior exactly
expect(await spanCount()).toBe(0);
await page.evaluate(() => {
const app = (window as any).app;
const s = app.loadAppSettingsFromStorage();
s.localEchoEnabled = true;
app.saveAppSettingsToStorage(s);
app._updateLocalEchoState();
});
await page.keyboard.press('Control+u');
await new Promise((r) => setTimeout(r, 200));
}, 30_000);
it('byte-identity: the wire receives the same bytes with the predictor active vs absent', async () => {
const script = async () => {
await resetComposer();
await page.keyboard.type('ab', { delay: 60 });
await page.keyboard.press('Backspace');
await page.keyboard.press('ArrowLeft');
await page.keyboard.type('c', { delay: 60 });
await page.evaluate(() => (window as any).app.terminal.paste('PQ'));
await new Promise((r) => setTimeout(r, 250));
await page.keyboard.press('End');
await page.keyboard.press('Control+u');
await new Promise((r) => setTimeout(r, 250));
};
const record = () =>
page.evaluate(() => {
const app = (window as any).app;
(window as any).__trace = [];
if (!(window as any).__origSend) (window as any).__origSend = app._sendInputAsync.bind(app);
app._sendInputAsync = (sid: string, data: string, opts?: unknown) => {
(window as any).__trace.push(data);
return (window as any).__origSend(sid, data, opts);
};
});
const trace = () => page.evaluate(() => ((window as any).__trace as string[]).join(''));
await record();
await script();
const withPredictor = await trace();
await page.evaluate(() => {
(window as any).__savedPredictor = (window as any).app._predictiveEcho;
(window as any).app._predictiveEcho = null;
});
await record();
await script();
const withoutPredictor = await trace();
await page.evaluate(() => {
(window as any).app._predictiveEcho = (window as any).__savedPredictor;
});
expect(withPredictor.length).toBeGreaterThan(0);
expect(withoutPredictor).toBe(withPredictor); // the visual-only invariant, end to end
}, 60_000);
it('#218: arrows + mid-word insert submit the exact edited text', async () => {
await resetComposer();
await page.keyboard.type('hello', { delay: 30 });
await new Promise((r) => setTimeout(r, 300));
await page.keyboard.press('ArrowLeft');
// Nav keys clear predictions immediately (classify 'clear')
expect(await predictState()).toMatchObject({ outstanding: 0 });
expect(await spanCount()).toBe(0);
await page.keyboard.press('ArrowLeft');
await page.keyboard.type('X', { delay: 30 });
await page.keyboard.press('Enter');
// The submitted transcript line carries the edited text
const deadline = Date.now() + 15000;
let pane = '';
while (Date.now() < deadline) {
pane = capturePaneFor(trustedWorkdir);
if (pane.includes('helXlo')) break;
await new Promise((r) => setTimeout(r, 300));
}
expect(pane).toContain('helXlo');
expect(await spanCount()).toBe(0);
await cancelRetryLoop();
}, 45_000);
it('simulated 300ms RTT: instant spans with correct pixel geometry, exact convergence', async () => {
await resetComposer();
// Delay every terminal.write chunk by 300ms: display-side injection only,
// the wire is untouched. This is the condition the feature exists for.
await page.evaluate(() => {
const app = (window as any).app;
(window as any).__origWrite = app.terminal.write.bind(app.terminal);
app.terminal.write = (data: unknown, cb?: () => void) =>
setTimeout(() => (window as any).__origWrite(data, cb), 300);
});
// Let any pre-wrapper chunks and their reconcile passes settle first
await new Promise((r) => setTimeout(r, 700));
const base = await predictState();
await page.keyboard.press('h');
// The span exists NOW, long before the delayed echo can land
const snap = await page.evaluate(() => {
const app = (window as any).app;
const span = document.querySelector('.xterm-screen [data-predictive-echo] span') as HTMLElement;
const screen = document.querySelector('.xterm-screen') as HTMLElement;
const dims = app.terminal._core._renderService.dimensions.css.cell;
const buf = app.terminal.buffer.active;
return span
? {
outstanding: app._predictiveEcho.state.outstanding,
spanLeft: span.getBoundingClientRect().left - screen.getBoundingClientRect().left,
spanTop: span.getBoundingClientRect().top - screen.getBoundingClientRect().top,
expectedLeft: buf.cursorX * dims.width,
expectedTop: buf.cursorY * dims.height,
text: span.textContent,
}
: null;
});
expect(snap).not.toBeNull();
expect(snap!.outstanding).toBe(1);
expect(snap!.text).toBe('h');
// Pixel geometry: the DOM span sits on the exact cell the echo will use
expect(Math.abs(snap!.spanLeft - snap!.expectedLeft)).toBeLessThan(1.5);
expect(Math.abs(snap!.spanTop - snap!.expectedTop)).toBeLessThan(1.5);
await page.keyboard.type('igh rtt', { delay: 40 });
// (No mid-flight outstanding assertion: with local codex the delayed echo
// begins confirming DURING the typing. The snap above already pinned the
// zero-lag property; convergence + the delta below pin the rest.)
// Full convergence after the delayed echo lands
await page.waitForFunction(
() => document.querySelectorAll('.xterm-screen [data-predictive-echo] span').length === 0,
undefined,
{ timeout: 15000 }
);
const deadline = Date.now() + 10000;
let pane = '';
while (Date.now() < deadline) {
pane = capturePaneFor(trustedWorkdir);
if (pane.includes('high rtt')) break;
await new Promise((r) => setTimeout(r, 250));
}
expect(pane).toContain('high rtt');
const after = await predictState();
expect(after.confirmedTotal - base.confirmedTotal).toBeGreaterThanOrEqual(6); // h + igh rtt
// #218 under RTT: arrows still edit correctly with the delayed display
await page.keyboard.press('ArrowLeft');
await page.keyboard.type('Z', { delay: 40 });
await page.keyboard.press('Enter');
const deadline2 = Date.now() + 15000;
let pane2 = '';
while (Date.now() < deadline2) {
pane2 = capturePaneFor(trustedWorkdir);
if (pane2.includes('high rtZt')) break;
await new Promise((r) => setTimeout(r, 300));
}
expect(pane2).toContain('high rtZt');
await page.evaluate(() => {
(window as any).app.terminal.write = (window as any).__origWrite;
});
}, 90_000);
});