From 2ca01eca9163024d864b4cae5bc805cd68052dc7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:55:50 -0700 Subject: [PATCH] test(e2e): keep the alt-screen restore path observable Numbering the snapshot frame one higher than the live-written frame keeps the marker assertion path-agnostic while leaving the frame number on screen as the signal for which path painted. An unrecognised frame now fails, and the per-cycle path is recorded rather than asserted because which cycles latch a restore is load-dependent. Frame authoring and readback move to a helper module; the additions crossed the spec's max-lines cap. Co-authored-by: Orca --- tests/e2e/helpers/alt-screen-frame.ts | 75 +++++++++++++++++++ ...terminal-tab-switch-visual-restore.spec.ts | 74 +++++++++--------- 2 files changed, 114 insertions(+), 35 deletions(-) create mode 100644 tests/e2e/helpers/alt-screen-frame.ts diff --git a/tests/e2e/helpers/alt-screen-frame.ts b/tests/e2e/helpers/alt-screen-frame.ts new file mode 100644 index 00000000000..ec25f9f5dbe --- /dev/null +++ b/tests/e2e/helpers/alt-screen-frame.ts @@ -0,0 +1,75 @@ +import type { Page } from '@stablyai/playwright-test' + +// Boxed alt-screen TUI frame: enters the alternate buffer, clears it, and paints +// a marker line carrying a zero-padded frame number. +export function buildAltScreenFrame(marker: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${marker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') +} + +// Why: the live-write and the reveal restore paint the same layout, so the frame +// number is the only thing on screen that says which of the two landed last. +export async function readRenderedAltScreenFrame( + page: Page, + tabId: string, + marker: string +): Promise { + return page.evaluate( + ({ tabId, marker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const pattern = new RegExp(`${marker} frame (\\d{3})`) + const buffer = pane.terminal.buffer.active + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + const match = pattern.exec(line) + if (match) { + return Number(match[1]) + } + } + return null + }, + { tabId, marker } + ) +} + +export function describeAltScreenRenderPath( + renderedFrame: number | null, + liveFrame: number, + restoreFrame: number +): string { + if (renderedFrame === restoreFrame) { + return 'reveal restore' + } + if (renderedFrame === liveFrame) { + return 'live write (no restore)' + } + return renderedFrame === null ? 'no marker' : `unexpected frame ${renderedFrame}` +} + +export async function writeToPaneTerminal(page: Page, tabId: string, data: string): Promise { + await page.evaluate( + ({ tabId, data }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + return new Promise((resolve) => pane.terminal.write(data, resolve)) + }, + { tabId, data } + ) +} diff --git a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts index 45fc01a600a..4f0d2a5df11 100644 --- a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts +++ b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts @@ -1,5 +1,11 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' +import { + buildAltScreenFrame, + describeAltScreenRenderPath, + readRenderedAltScreenFrame, + writeToPaneTerminal +} from './helpers/alt-screen-frame' import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal' import { ensureTerminalVisible, @@ -289,35 +295,6 @@ async function injectPaneData( } } -function buildAltScreenFrame(marker: string, frame: number): string { - const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` - return [ - '\x1b[?2026h', - '\x1b[?1049h', - '\x1b[2J\x1b[H', - '\x1b[?25l', - `╭────────────────────────────────────────────────────────────────────╮`, - `│ ${marker} frame ${String(frame).padStart(3, '0')} ${progress} │`, - `│ Dimension │ Rating │`, - `╰────────────────────────────────────────────────────────────────────╯`, - '\x1b[?2026l' - ].join('\r\n') -} - -async function writeToPaneTerminal(page: Page, tabId: string, data: string): Promise { - await page.evaluate( - ({ tabId, data }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] - if (!pane) { - throw new Error(`No terminal pane for tab ${tabId}`) - } - return new Promise((resolve) => pane.terminal.write(data, resolve)) - }, - { tabId, data } - ) -} - async function setHiddenSnapshotOverride( page: Page, ptyId: string, @@ -652,20 +629,38 @@ test.describe('Terminal tab switch visual restore', () => { ) const corruptionReports: string[] = [] + const renderPaths: string[] = [] for (let cycle = 0; cycle < 6; cycle += 1) { - const redraw = buildAltScreenFrame(finalMarker, cycle * 4) + const liveFrame = cycle * 4 + const restoreFrame = liveFrame + 1 + const redraw = buildAltScreenFrame(finalMarker, liveFrame) // Why: this frame never transits the PTY, so a reveal restore would - // repaint main's model over it. Publish it as the snapshot too, so both - // the live-write and restore paths land the same frame. Identity is - // re-read per cycle because a reattach would re-key the override. + // repaint main's model over it. Publish an equivalent frame as the + // snapshot so either path leaves a valid screen — numbered one higher so + // the readback still reports which one painted. Identity is re-read per + // cycle because a reattach would re-key the override. const { ptyId, cols, rows } = await readPaneIdentityOnTab(orcaPage, firstTabId) - await setHiddenSnapshotOverride(orcaPage, ptyId, { data: redraw, cols, rows }) + await setHiddenSnapshotOverride(orcaPage, ptyId, { + data: buildAltScreenFrame(finalMarker, restoreFrame), + cols, + rows + }) await activateTerminalTab(orcaPage, secondTabId) await writeToPaneTerminal(orcaPage, firstTabId, redraw) await activateTerminalTab(orcaPage, firstTabId) const geometry = await readTabTerminalGeometry(orcaPage, firstTabId, `${runId}_ALT`) - const issue = geometryLooksCorrupted(geometry) + const renderedFrame = await readRenderedAltScreenFrame(orcaPage, firstTabId, finalMarker) + renderPaths.push( + `cycle ${cycle}: ${describeAltScreenRenderPath(renderedFrame, liveFrame, restoreFrame)}` + ) + // Why: whichever path won must have painted its own frame. Anything else + // on screen means the restore replayed stale content. + const staleFrame = + renderedFrame !== null && renderedFrame !== liveFrame && renderedFrame !== restoreFrame + ? `alt-screen shows frame ${renderedFrame}, expected ${liveFrame} (live write) or ${restoreFrame} (reveal restore)` + : null + const issue = geometryLooksCorrupted(geometry) ?? staleFrame if (issue || !geometry.markerPresent) { corruptionReports.push( `cycle ${cycle}: ${issue ?? 'marker missing after alt-screen redraw'}` @@ -679,6 +674,15 @@ test.describe('Terminal tab switch visual restore', () => { } } + // Why: which cycles latched a restore is load-dependent, so it is recorded + // rather than asserted — without it a "both paths agree" run is opaque. + // Logged as well because the list reporter omits annotations. + testInfo.annotations.push({ + type: 'alt-screen-render-path', + description: renderPaths.join(', ') + }) + console.log('[tab-switch-repro] alt-screen render path:', renderPaths.join(', ')) + expect( corruptionReports, corruptionReports.length > 0