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 <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-25 14:55:50 -07:00
co-authored by Orca
parent 0fa49cf162
commit 2ca01eca91
2 changed files with 114 additions and 35 deletions
+75
View File
@@ -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<number | null> {
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<void> {
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<void>((resolve) => pane.terminal.write(data, resolve))
},
{ tabId, data }
)
}
@@ -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<void> {
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<void>((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