mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
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>
This commit is contained in:
@@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
'test/opencode-resize.test.ts', // browser (Playwright)
|
'test/opencode-resize.test.ts', // browser (Playwright)
|
||||||
'test/webgl-fallback.test.ts', // browser (Playwright)
|
'test/webgl-fallback.test.ts', // browser (Playwright)
|
||||||
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
|
'test/terminal-copy-shortcut.test.ts', // browser (Playwright)
|
||||||
|
'test/codex-predictive-echo.test.ts', // browser (Playwright) + real codex binary
|
||||||
],
|
],
|
||||||
setupFiles: ['./test/setup.ts'],
|
setupFiles: ['./test/setup.ts'],
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user