test: correct 8 stale specs surfaced by the test-detected-bugs sweep (#17434)

This commit is contained in:
Neil
2026-08-30 11:45:21 -07:00
committed by GitHub
parent 89a4d67705
commit 6677ae4e5e
20 changed files with 1031 additions and 218 deletions
@@ -1,8 +1,13 @@
import { describe, expect, it } from 'vitest'
import { BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY,
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES
} from '../../../../shared/protocol-version'
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
import {
assertProjectedSessionTabVisible,
clientCanObserveClientHostedBrowserPages,
projectSessionTabBrowserPlacements,
translateProjectedSessionTabMove
} from './session-tab-browser-placement-projection'
@@ -36,6 +41,21 @@ describe('projectSessionTabBrowserPlacements', () => {
expect(projected.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-terminal' })
})
// Why named here: a CLI socket sends no capabilities at all, so anything asking it for browser
// tabs — an e2e oracle included — is blind to every client-placed page by design.
it('hides client-placed pages from a native peer and from a caller with no capabilities', () => {
expect(
clientCanObserveClientHostedBrowserPages(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES)
).toBe(false)
expect(
clientCanObserveClientHostedBrowserPages(ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES)
).toBe(true)
expect(clientCanObserveClientHostedBrowserPages(undefined)).toBe(false)
expect(projectSessionTabBrowserPlacements(makeSnapshot(), undefined).tabs).toEqual([
expect.objectContaining({ id: 'terminal-leaf' })
])
})
it('preserves hidden raw slots while translating an old-client reorder', () => {
const raw = mixedGroupSnapshot()
const projected = projectSessionTabBrowserPlacements(raw, [])
@@ -19,7 +19,10 @@ import {
isIntentionalAppRestartInProgress,
registerUpdaterBeforeUnloadBypass
} from '../lib/updater-beforeunload'
import { createShutdownCheckpointPersist } from './shutdown-checkpoint-persist'
import {
createShutdownCheckpointPersist,
type ShutdownCheckpointPersistDeps
} from './shutdown-checkpoint-persist'
type LifecycleHarness = {
cleanup: () => void
@@ -27,9 +30,14 @@ type LifecycleHarness = {
stageBeforeUnloadSync: ReturnType<typeof vi.fn>
}
type LifecycleHarnessOverrides = Partial<
Pick<ShutdownCheckpointPersistDeps, 'buildSessionSnapshots' | 'hasDirtyOpenFiles'>
>
function createLifecycleHarness(
startedEventName: string,
abortedEventName: string
abortedEventName: string,
overrides: LifecycleHarnessOverrides = {}
): LifecycleHarness {
const stageBeforeUnloadSync = vi.fn((args: { sessions: unknown[] }) => {
if (args.sessions.length > 0) {
@@ -44,7 +52,8 @@ function createLifecycleHarness(
buildUiPatch: () => ({ activeView: 'workspace' }) as never,
hasDirtyOpenFiles: () => false,
isDegradableShutdownInProgress: isIntentionalAppRestartInProgress,
stageBeforeUnloadSync
stageBeforeUnloadSync,
...overrides
})
const guard = createShutdownCheckpointGuard(persist.run, persist.abandonAttempt)
const checkpoint = createShutdownCheckpointBeforeUnloadHandler(guard)
@@ -130,4 +139,28 @@ describe('shutdown checkpoint restart lifecycle', () => {
expect(harness.stageBeforeUnloadSync).toHaveBeenCalledTimes(2)
})
// Mirrors the e2e fixture in tests/e2e/update-install-renderer-checkpoint-recovery.spec.ts,
// which asserted the pre-STA-5505 bare message long after the cause suffix landed (STA-5668).
it('names the snapshot-build cause when dirty drafts block the checkpoint', async () => {
const snapshotFailure = "Cannot read properties of null (reading 'toLowerCase')"
vi.spyOn(console, 'error').mockImplementation(() => {})
const harness = createLifecycleHarness(
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
{
buildSessionSnapshots: () => {
throw new Error(snapshotFailure)
},
hasDirtyOpenFiles: () => true
}
)
cleanupFns.push(harness.cleanup)
await expect(harness.prepare()).rejects.toThrow(
new Error(`Renderer shutdown checkpoint was not completed: ${snapshotFailure}`)
)
// Dirty drafts must block before any durable-only degrade stages over them.
expect(harness.stageBeforeUnloadSync).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useAppStore } from '@/store'
import type { Worktree } from '../../../../shared/worktree/types'
import { makeWorktree } from '../worktree-jump-palette-test-fixtures'
import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog'
import { useWorktreeStatusMutations } from './worktree-list/drag/use-status-mutations'
/**
* The sidebar drop only reorders if the payload `reorderWorktrees` builds is the
* one `updateWorktreesMeta` consumes. The e2e that covered this replaced the store
* action with a fake, so a payload-shape change (#16691, Map -> batch array) showed
* up as a red drag spec instead of a red contract. This runs the real action.
*/
const initialState = useAppStore.getInitialState()
const REPO_ID = 'repo-manual-order'
const GROUP_KEY = `repo:${REPO_ID}`
const updateMeta = vi.fn().mockResolvedValue(undefined)
function seedManualOrderedRows(count: number): Worktree[] {
const worktrees = Array.from({ length: count }, (_, index) =>
// The store routes a metadata write by the repo id embedded in the worktree id.
makeWorktree(`${REPO_ID}::manual-${String(index).padStart(2, '0')}`, `Manual ${index}`, {
repoId: REPO_ID,
manualOrder: 100_000 - index
})
)
useAppStore.setState({
sortBy: 'smart',
worktreesByRepo: { [REPO_ID]: worktrees }
})
return worktrees
}
function renderReorder(worktrees: readonly Worktree[]) {
return renderHook(() =>
useWorktreeStatusMutations({
worktreeMap: new Map(worktrees.map((worktree) => [worktree.id, worktree])),
manualOrderCatalog: buildWorktreeManualOrderCatalog({
worktrees,
folderWorkspaces: []
}),
workspaceStatuses: [],
sortBy: 'smart'
})
).result
}
function manualOrderedIds(): readonly string[] {
return buildWorktreeManualOrderCatalog({
worktrees: useAppStore.getState().worktreesByRepo[REPO_ID] ?? [],
folderWorkspaces: []
}).orderedIds
}
describe('sidebar manual-order drop', () => {
beforeEach(() => {
useAppStore.setState(initialState, true)
updateMeta.mockClear()
Object.assign(window, { api: { worktrees: { updateMeta } } })
})
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
it('moves the dragged row past its neighbor in the store', async () => {
const worktrees = seedManualOrderedRows(3)
const ids = worktrees.map((worktree) => worktree.id)
const reorder = renderReorder(worktrees)
await act(async () => {
reorder.current.reorderWorktrees({
groups: [{ key: GROUP_KEY, worktreeIds: ids }],
sourceGroupKey: GROUP_KEY,
draggedIds: [ids[0]!],
dropIndex: 2
})
})
expect(manualOrderedIds()).toEqual([ids[1], ids[0], ids[2]])
expect(useAppStore.getState().sortBy).toBe('manual')
expect(updateMeta).toHaveBeenCalledWith({
worktreeId: ids[0],
executionHostId: 'local',
updates: { manualOrder: expect.any(Number) }
})
})
it('leaves the order alone when the drop lands where the row already is', async () => {
const worktrees = seedManualOrderedRows(3)
const ids = worktrees.map((worktree) => worktree.id)
const reorder = renderReorder(worktrees)
const epochBeforeDrop = useAppStore.getState().sortEpoch
await act(async () => {
reorder.current.reorderWorktrees({
groups: [{ key: GROUP_KEY, worktreeIds: ids }],
sourceGroupKey: GROUP_KEY,
draggedIds: [ids[0]!],
dropIndex: 1
})
})
expect(manualOrderedIds()).toEqual(ids)
expect(useAppStore.getState().sortBy).toBe('smart')
expect(useAppStore.getState().sortEpoch).toBe(epochBeforeDrop)
expect(updateMeta).not.toHaveBeenCalled()
})
})
@@ -210,6 +210,40 @@ describe('buildSearchableBrowserPages', () => {
])
})
it('re-hosts a same-id page entry when the sibling row is missing from the catalog', () => {
// Why: host qualification is gated on both same-id rows being present. With one reaped, a
// local-stamped tab still renders but carries the surviving row's host — so a wrong-host
// Cmd-J activation means the catalog lost a row, not that host qualification regressed.
// This characterizes today's fallback, it does not bless it: overriding a tab's own 'local'
// stamp may be the wrong answer, and changing it is tracked as the unified-tab-host-ownership
// follow-up. Update this expectation with that change rather than treating it as a contract.
const sharedId = 'repo-shared::/workspace'
const remote = makeWorktree({ id: sharedId, hostId: 'runtime:host-b' })
const entries = buildSearchableBrowserPages({
worktrees: [remote],
repoMap,
worktreeOrder: new Map([[getWorktreeHostIdentity(remote), 0]]),
browserTabsByWorktree: {
[sharedId]: [
makeWorkspace({ id: 'ws-local', worktreeId: sharedId, activePageId: 'page-local' })
]
},
browserPagesByWorkspace: {
'ws-local': [makePage({ id: 'page-local', workspaceId: 'ws-local', worktreeId: sharedId })]
},
unifiedTabsByWorktree: {
[sharedId]: [browserUnifiedTab('tab-local', 'ws-local', sharedId, 'local')]
},
activeBrowserTabId: null,
activeWorktreeId: null,
activeTabType: 'terminal'
})
expect(entries.map((entry) => [entry.page.id, entry.executionHostId])).toEqual([
['page-local', 'runtime:host-b']
])
})
it('does not route one ambiguous legacy browser bucket to both hosts', () => {
const sharedId = 'repo-shared::/workspace'
const workspace = makeWorkspace({ worktreeId: sharedId })
@@ -0,0 +1,15 @@
// Why an alternate-screen fixture must outlive the assertions that read it:
// main's dead-TUI recovery barrier (src/main/daemon/terminal-shell-recovery-barrier.ts)
// injects `\x1b[?1049l` as soon as a shell prompt proves an alternate-screen owner
// exited without its own cleanup, so a fixture that paints and exits has its frames
// discarded before a spec can observe them. Specs Ctrl-C them once done reading.
/** Node statement that keeps a fixture — and so its alternate screen — the live PTY foreground. */
export const HOLD_ALTERNATE_SCREEN_OPEN = 'setInterval(() => {}, 1000)'
/** Fixture program: paint `payload`, then stay the live alternate-screen owner. */
export function alternateScreenFixtureScript(payload: string, delayMs = 0): string {
const write = `process.stdout.write(${JSON.stringify(payload)})`
const paint = delayMs > 0 ? `setTimeout(() => ${write}, ${delayMs})` : write
return `${paint}\n${HOLD_ALTERNATE_SCREEN_OPEN}\n`
}
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
HOLD_ALTERNATE_SCREEN_OPEN,
alternateScreenFixtureScript
} from './alternate-screen-fixture-script'
import {
type HiddenPressureOutputMode,
pressureOutputScript
} from './artificial-opencode-hidden-pressure-script'
// The escape as it appears in generated source, where it is still a JS string escape.
const ALTERNATE_SCREEN_ENTER_SOURCE = '\\x1b[?1049h'
const PRESSURE_MODES: HiddenPressureOutputMode[] = ['tui', 'plain', 'title', 'latin', 'rich-model']
describe('alternateScreenFixtureScript', () => {
it('holds the painting process open so the alternate screen survives the assertions', () => {
const source = alternateScreenFixtureScript('\x1b[?1049hFRAME')
expect(source).toContain(HOLD_ALTERNATE_SCREEN_OPEN)
expect(source).toContain('process.stdout.write("\\u001b[?1049hFRAME")')
expect(source).not.toContain('setTimeout')
})
it('defers the paint by the requested delay and still holds open', () => {
const source = alternateScreenFixtureScript('FRAME', 750)
expect(source).toContain('setTimeout(() => process.stdout.write("FRAME"), 750)')
expect(source).toContain(HOLD_ALTERNATE_SCREEN_OPEN)
})
})
describe('hidden pressure fixture', () => {
// Ratchet: entering the alternate screen and exiting is the dead-TUI shape the
// recovery barrier deliberately dismisses, which silently emptied these panes.
it.each(PRESSURE_MODES)(
'keeps mode %s alive exactly when it enters the alternate screen',
(mode) => {
const source = pressureOutputScript('run-id', mode)
expect(source.includes(HOLD_ALTERNATE_SCREEN_OPEN)).toBe(
source.includes(ALTERNATE_SCREEN_ENTER_SOURCE)
)
}
)
it('holds the rich-model alternate screen open past its done marker', () => {
const source = pressureOutputScript('run-id', 'rich-model')
expect(source).toContain(ALTERNATE_SCREEN_ENTER_SOURCE)
expect(source.indexOf(HOLD_ALTERNATE_SCREEN_OPEN)).toBeGreaterThan(
source.indexOf('OPENCODE_PRESSURE_DONE_')
)
})
})
// Ratchet: the builder is only worth anything while the specs still route through it,
// and a fixture hand-rolled back to paint-and-exit would surface days later in a
// scheduled Electron run — the detection channel that produced this ticket.
describe('spec alternate-screen fixtures', () => {
const SPEC_FIXTURE_BUILDERS: [spec: string, builder: string][] = [
['terminal-hidden-view-parking.spec.ts', 'writeParkedFrameScript'],
['terminal-hidden-view-parking.spec.ts', 'writeCycleReferenceScript'],
['terminal-hidden-tui-visual-restore.spec.ts', 'writeHiddenFrameScript']
]
function readSpec(spec: string): string {
return readFileSync(new URL(spec, import.meta.url), 'utf8')
}
function topLevelFunctionBody(source: string, name: string): string {
const start = source.indexOf(`function ${name}(`)
expect(start, `${name} was renamed or removed; re-point this ratchet`).toBeGreaterThan(-1)
// Why '\n}' and a bound: '\n}\n' misses CRLF checkouts, and an unresolved indexOf slices to EOF,
// letting a later builder call in the same file satisfy the assertion vacuously.
const end = source.indexOf('\n}', start)
expect(end, `${name} has no closing brace; re-point this ratchet`).toBeGreaterThan(start)
return source.slice(start, end)
}
it.each(SPEC_FIXTURE_BUILDERS)('%s writes %s through the shared builder', (spec, builder) => {
expect(topLevelFunctionBody(readSpec(spec), builder)).toContain('alternateScreenFixtureScript(')
})
it('the OSC 8 spec stages its link fixture through the shared builder', () => {
expect(readSpec('terminal-osc8-cold-park-restore.spec.ts')).toContain(
'stageNodeScriptForTerminal(alternateScreenFixtureScript('
)
})
})
@@ -1,5 +1,6 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { HOLD_ALTERNATE_SCREEN_OPEN } from './alternate-screen-fixture-script'
export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' | 'rich-model'
@@ -16,6 +17,10 @@ export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMo
: mode === 'rich-model'
? "'\\x1b[?2026h\\x1b[?1049h\\x1b[2J\\x1b[H\\x1b[?25l\\x1b[2;36m╭────────────────────────────────────────╮\\x1b[0m\\r\\n\\x1b[2;36m│ rich model pane=' + paneIndex + ' frame=' + frame + ' 😀 ███░ │\\x1b[0m\\r\\n\\x1b[2;36m│ ' + chunkBody + ' │\\x1b[0m\\r\\n\\x1b[2;36m╰────────────────────────────────────────╯\\x1b[0m\\x1b[6;4H\\x1b[?25h\\x1b[?2026l\\n'"
: "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'"
// Why only rich-model: it is the one mode that enters the alternate screen, and an
// alt-screen owner that exits is exactly the dead TUI main's recovery barrier
// discards — frames and done marker with it. Scenario cleanup Ctrl-Cs the pane.
const holdOpen = mode === 'rich-model' ? `\n${HOLD_ALTERNATE_SCREEN_OPEN}` : ''
return `
const paneIndex = process.argv[2] ?? '0'
const targetChars = Number(process.argv[3] ?? '0')
@@ -38,7 +43,7 @@ function writeMore() {
}
process.stdout.write('${donePrefix}OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n')
}
setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0)
setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0)${holdOpen}
`
}
@@ -447,7 +447,7 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({
scheduler,
mainPressure
)
expect(scheduler?.rendererDroppedBacklogs ?? 0).toBe(0)
expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0)
expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS)
expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS)
expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_UNDER_LOAD_MS)
+43 -21
View File
@@ -17,6 +17,47 @@ export function buildAltScreenFrame(marker: string, frame: number): string {
].join('\r\n')
}
export type ActiveScreen = {
bufferType: 'normal' | 'alternate'
rows: string[]
}
// Why: what the pane shows is the active buffer's viewport. `serializeAddon.serialize()`
// dumps the whole normal buffer (scrollback included) before the alt frame, so a stale
// marker there is indistinguishable from the live one (STA-5208). Null on a missing pane
// because callers poll this and `expect.poll` aborts on a generator throw.
export async function readActiveScreen(page: Page, tabId: string): Promise<ActiveScreen | null> {
return page.evaluate(
({ tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
return null
}
const buffer = pane.terminal.buffer.active
const rows: string[] = []
for (let row = 0; row < pane.terminal.rows; row += 1) {
rows.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '')
}
return { bufferType: buffer.type, rows }
},
{ tabId }
)
}
// Why the highest match rather than the first: a repaint can leave an older marker line
// beside the live one, and only the newest frame says which paint landed last.
export function findMarkerFrame(text: string, marker: string): number | null {
// marker is a literal, so escape it rather than letting `[`/`.`/`+` act as regex syntax.
const pattern = new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} frame (\\d+)`, 'g')
let latest: number | null = null
for (const match of text.matchAll(pattern)) {
const frame = Number(match[1])
latest = latest === null ? frame : Math.max(latest, frame)
}
return latest
}
// 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(
@@ -24,27 +65,8 @@ export async function readRenderedAltScreenFrame(
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}`)
}
// marker is a literal, so escape it rather than letting `[`/`.`/`+` act as regex syntax.
const pattern = new RegExp(`${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} 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 }
)
const screen = await readActiveScreen(page, tabId)
return screen ? findMarkerFrame(screen.rows.join('\n'), marker) : null
}
export function describeAltScreenRenderPath(
@@ -0,0 +1,111 @@
// STA-5208: the duplicate-PTY reveal oracle parsed `serializeAddon.serialize()`, which
// concatenates the whole normal buffer (scrollback included) ahead of the alt frame, so a
// stale marker left behind by the pre-hide paint was read as the revealed pane's current
// frame. These pin the replacement oracle: read the frame off the active buffer's viewport.
import '../../../src/main/daemon/xterm-env-polyfill'
import { describe, expect, it } from 'vitest'
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import type { Page } from '@stablyai/playwright-test'
import { findMarkerFrame, readActiveScreen, readRenderedAltScreenFrame } from './alt-screen-frame'
const MARKER = 'DUPLICATE_PTY_REVEAL_TEST'
const TAB_ID = 'tab-under-test'
type Harness = { page: Page; terminal: Terminal; serialize: () => string }
// Matches the streaming TUI fixture in terminal-duplicate-pty-renderer-reveal.spec.ts.
function frameLine(frame: number): string {
return `${MARKER} frame ${String(frame).padStart(6, '0')}`
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, () => resolve()))
}
// Runs the helper's real in-page closure against a headless terminal so the test covers
// the code the browser executes rather than a copy of it.
function createHarness(rows: number): Harness {
const terminal = new Terminal({ cols: 80, rows, scrollback: 100, allowProposedApi: true })
const serializeAddon = new SerializeAddon()
terminal.loadAddon(serializeAddon)
const pane = { terminal, serializeAddon }
;(globalThis as Record<string, unknown>).__paneManagers = new Map([
[TAB_ID, { getPanes: () => [pane] }]
])
const page = {
evaluate: <Arg, Result>(fn: (arg: Arg) => Result, arg: Arg): Promise<Result> =>
Promise.resolve(fn(arg))
} as unknown as Page
return { page, terminal, serialize: () => serializeAddon.serialize() }
}
// The oracle this replaces: a positional scan over every buffer serialize() emits.
function parseSerializedFrame(content: string, pick: 'first' | 'last'): number | null {
const prefix = `${MARKER} frame `
const start = pick === 'first' ? content.indexOf(prefix) : content.lastIndexOf(prefix)
if (start < 0) {
return null
}
const digits = content.slice(start + prefix.length).match(/^\d+/)?.[0]
return digits ? Number(digits) : null
}
describe('readRenderedAltScreenFrame', () => {
it('reads the live alt frame, not the stale copy left in the normal buffer', async () => {
const harness = createHarness(8)
await write(harness.terminal, `${frameLine(390)}\r\n`)
await write(harness.terminal, `\x1b[?1049h\x1b[H${frameLine(400)}\x1b[J`)
const serialized = harness.serialize()
expect(parseSerializedFrame(serialized, 'first')).toBe(390)
expect(parseSerializedFrame(serialized, 'last')).toBe(400)
await expect(readActiveScreen(harness.page, TAB_ID)).resolves.toMatchObject({
bufferType: 'alternate'
})
await expect(readRenderedAltScreenFrame(harness.page, TAB_ID, MARKER)).resolves.toBe(400)
})
it('reports no frame when the freshest marker scrolled off the visible rows', async () => {
const harness = createHarness(8)
await write(harness.terminal, `${frameLine(400)}\r\n`)
for (let row = 0; row < 12; row += 1) {
await write(harness.terminal, `filler row ${row}\r\n`)
}
// serialize() still contains the marker from scrollback, so it cannot see that nothing
// correct is on screen; the viewport read can.
expect(parseSerializedFrame(harness.serialize(), 'last')).toBe(400)
await expect(readActiveScreen(harness.page, TAB_ID)).resolves.toMatchObject({
bufferType: 'normal'
})
await expect(readRenderedAltScreenFrame(harness.page, TAB_ID, MARKER)).resolves.toBeNull()
})
// expect.poll aborts on a generator throw, so a pane mid-remount has to read as
// "not converged yet" rather than ending the poll.
it('returns null when the tab has no pane', async () => {
const harness = createHarness(8)
await expect(readActiveScreen(harness.page, 'tab-without-pane')).resolves.toBeNull()
await expect(
readRenderedAltScreenFrame(harness.page, 'tab-without-pane', MARKER)
).resolves.toBeNull()
})
})
describe('findMarkerFrame', () => {
it('reads frame numbers of any width', () => {
expect(findMarkerFrame(`${MARKER} frame 000400`, MARKER)).toBe(400)
expect(findMarkerFrame(`| ${MARKER} frame 024 |`, MARKER)).toBe(24)
})
it('takes the highest frame when several are on screen', () => {
expect(findMarkerFrame([frameLine(400), frameLine(390)].join('\n'), MARKER)).toBe(400)
})
it('treats the marker as a literal', () => {
expect(findMarkerFrame('A[B frame 7', 'A[B')).toBe(7)
expect(findMarkerFrame('AxB frame 7', 'A[B')).toBeNull()
})
})
@@ -0,0 +1,26 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
/**
* Renderer-side counterpart of `forwardElectronProcessLogs`, sharing its
* `ORCA_E2E_FORWARD_APP_LOGS` gate.
*
* Why: a contained render crash only ever reaches the renderer console
* (`RecoverableRenderErrorBoundary` logs the error plus its component stack
* there), so without this a boundary failure leaves nothing but a screenshot of
* the dialog and the stack that would localize the first bad render is lost.
*/
export function forwardRendererConsole(page: Page, testInfo: TestInfo): void {
if (process.env.ORCA_E2E_FORWARD_APP_LOGS !== '1') {
return
}
const prefix = `[renderer:${testInfo.title}]`
page.on('console', (message) => {
if (message.type() === 'error' || message.type() === 'warning') {
console.error(`${prefix} ${message.type()}: ${message.text()}`)
}
})
page.on('pageerror', (error) => {
console.error(`${prefix} pageerror: ${error.stack ?? error.message}`)
})
}
+16
View File
@@ -154,6 +154,22 @@ export async function waitForSessionReady(page: Page, timeoutMs = 30_000): Promi
.toBe(true)
}
/**
* Wait until the deferred startup worktree scan has completed.
*
* Why: hydration fires an unawaited full catalog refresh after
* `workspaceSessionReady`; a fixture seeded before it lands is silently
* overwritten when it does.
*/
export async function waitForStartupWorktreeRefresh(page: Page, timeoutMs = 60_000): Promise<void> {
await expect
.poll(async () => getStoreState<boolean>(page, 'startupWorktreeRefreshCompleted'), {
timeout: timeoutMs,
message: 'startupWorktreeRefreshCompleted did not become true'
})
.toBe(true)
}
/** Wait until a worktree is active and return its ID. */
export async function waitForActiveWorktree(page: Page, timeoutMs = 30_000): Promise<string> {
let activeWorktreeId: string | null = null
@@ -1,19 +1,26 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { Page } from '@stablyai/playwright-test'
import { parseBrowserNetworkExecutionHostKey } from '../../src/main/browser/browser-network-execution-route'
import { LOCAL_EXECUTION_HOST_ID } from '../../src/shared/execution-host'
import { readOwnedPageUrls } from './helpers/client-hosted-browser-observer'
import {
launchHeadlessPairedRuntimeHost,
type HeadlessPairedRuntimeHost
} from './helpers/headless-paired-runtime-host'
import { readHostBrowserPageUrls } from './helpers/host-session-tabs'
import { expect, test } from './helpers/orca-app'
import {
launchPairedElectronClient,
type PairedElectronClient
} from './helpers/paired-electron-client'
// The link is a dev-server URL, so a client-local fallback would silently load a *different
// machine's* server while looking successful.
// The link is a dev-server URL on the pane runtime's network, so a client-local fallback would
// silently load a *different machine's* server. Which machine renders the pixels no longer answers
// that: under client-hosted placement the guest paints on this desktop while its network is still
// pinned to the host at creation. So each act below pins the placement it was written for and reads
// the host's own record — the page row's placement and executionHostKey — instead of inferring
// routing from where a <webview> appeared.
const PANE_PATH = '/remote-pane'
const LINK_PATH = '/remote-link-target'
@@ -77,8 +84,14 @@ async function startLinkFixtureServer(): Promise<LinkFixtureServer> {
}
}
/** Asked over the host's own connection, not proxied through the client under test. */
async function readHostBrowserUrls(
/**
* The host's session-tab view, asked over the host's own CLI socket rather than proxied through the
* client under test.
*
* Server-placed pages only: this socket advertises no `BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY`, so
* the host strips every client-placed page from the snapshot before answering.
*/
async function readHostServerPlacedBrowserUrls(
host: HeadlessPairedRuntimeHost,
worktreeId: string
): Promise<string[]> {
@@ -90,7 +103,57 @@ async function readHostBrowserUrls(
return response.result.tabs.filter((tab) => tab.type === 'browser').map((tab) => tab.url ?? '')
}
/** A remote pane is a screencast image; a <webview> means the page really loaded on this machine. */
type HostBrowserRow = {
executionHostKey: string | null
placementKind: string | null
url: string
}
/**
* The host's rows for one URL, asked through the paired client's connection.
*
* Why through the client: an Electron peer advertises the client-host capability, so the host
* answers it with client-placed pages intact and with the placement and network pin it minted at
* creation. The host still authors every field; the client is only the transport.
*/
async function readHostBrowserRows(
page: Page,
environmentId: string,
worktreeId: string,
urlPrefix: string
): Promise<HostBrowserRow[]> {
return page.evaluate(
async ({ environmentId, urlPrefix, worktreeId }) => {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'session.tabs.list',
params: { worktree: `id:${worktreeId}` },
timeoutMs: 15_000
})
if (!response.ok) {
throw new Error('host session tab inventory unavailable')
}
const { tabs } = response.result as {
tabs: {
type: string
url?: string
executionHostKey?: string
placement?: { kind: string }
}[]
}
return tabs
.filter((tab) => tab.type === 'browser' && (tab.url ?? '').startsWith(urlPrefix))
.map((tab) => ({
executionHostKey: tab.executionHostKey ?? null,
placementKind: tab.placement?.kind ?? null,
url: tab.url ?? ''
}))
},
{ environmentId, urlPrefix, worktreeId }
)
}
/** Under server placement the client renders nothing itself, so any <webview> is a local fallback. */
async function readLocalBrowserViewUrls(page: Page): Promise<string[]> {
return page.evaluate(() =>
Array.from(document.querySelectorAll('webview')).map(
@@ -117,17 +180,22 @@ async function findMirroredPage(
page: Page,
worktreeId: string,
url: string
): Promise<{ handleEnvironmentId: string | null; pageId: string } | null> {
): Promise<{
handleEnvironmentId: string | null
pageId: string
placementKind: string | null
} | null> {
return page.evaluate(
({ url, worktreeId }) => {
const state = window.__store?.getState()
for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) {
for (const browserPage of state?.browserPagesByWorkspace[workspace.id] ?? []) {
if (browserPage.url.startsWith(url)) {
const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id]
return {
handleEnvironmentId:
state?.remoteBrowserPageHandlesByPageId[browserPage.id]?.environmentId ?? null,
pageId: browserPage.id
handleEnvironmentId: handle?.environmentId ?? null,
pageId: browserPage.id,
placementKind: handle?.placement?.kind ?? null
}
}
}
@@ -149,13 +217,50 @@ async function focusMirroredPage(page: Page, worktreeId: string, pageId: string)
)
}
/** Placement is a user setting whose default has already flipped once, so every act pins its own. */
async function pinClientHostedPlacement(page: Page, enabled: boolean): Promise<void> {
await page.evaluate(async (enabled) => {
await window.__store?.getState().updateSettings({ browserClientHostedRemoteEnabled: enabled })
}, enabled)
expect(
await page.evaluate(
() => window.__store?.getState().settings?.browserClientHostedRemoteEnabled ?? null
),
'the placement setting this act is written for did not take'
).toBe(enabled)
}
/** Leaves the workspace holding only the screencast pane the next act right-clicks. */
async function closeBrowserTabsExceptPane(
page: Page,
worktreeId: string,
paneUrl: string
): Promise<void> {
await page.evaluate(
({ paneUrl, worktreeId }) => {
const state = window.__store?.getState()
for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) {
const pages = state?.browserPagesByWorkspace[workspace.id] ?? []
if (!pages.some((browserPage) => browserPage.url.startsWith(paneUrl))) {
state?.closeBrowserTab(workspace.id)
}
}
},
{ paneUrl, worktreeId }
)
}
type LinkOpenOutcome = 'opened on this machine' | 'pending' | 'refused'
async function readLinkOpenOutcome(page: Page, linkUrl: string): Promise<LinkOpenOutcome> {
if ((await readLocalBrowserViewUrls(page)).some((url) => url.startsWith(linkUrl))) {
/** The previous act's guest is settled away before this runs, so any page here is the fallback. */
async function readLinkOpenOutcome(
client: PairedElectronClient,
linkUrl: string
): Promise<LinkOpenOutcome> {
if ((await readOwnedPageUrls(client.app, linkUrl)).length > 0) {
return 'opened on this machine'
}
const notice = page.getByTestId('remote-browser-stream-error')
const notice = client.page.getByTestId('remote-browser-stream-error')
const text = (await notice.count()) > 0 ? ((await notice.first().textContent()) ?? '') : ''
return text.includes('Unable to open URL.') ? 'refused' : 'pending'
}
@@ -172,7 +277,7 @@ async function openLinkFromRemotePaneContextMenu(page: Page): Promise<void> {
await openInOrca.click()
}
test('opens a remote pane link on the pane runtime and refuses to fall back to the client', async ({
test('opens a remote pane link on the pane runtime under either placement and refuses to fall back to the client', async ({
testRepoPath
}, testInfo) => {
test.setTimeout(300_000)
@@ -181,7 +286,8 @@ test('opens a remote pane link on the pane runtime and refuses to fall back to t
let client: PairedElectronClient | null = null
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })
const hostRuntimeId = (await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }))
._meta.runtimeId
client = await launchPairedElectronClient(host.offer, testInfo, 'Remote browser link routing')
const page = client.page
const environmentId = client.environmentId
@@ -199,6 +305,8 @@ test('opens a remote pane link on the pane runtime and refuses to fall back to t
throw new Error('paired client did not receive the host worktree')
}
const worktreeSelector = `id:${worktreeId}`
// The workspace runs on the paired runtime, the way it does when the user picks that host.
await page.evaluate(
({ environmentId, worktreeId }) => {
@@ -227,13 +335,15 @@ test('opens a remote pane link on the pane runtime and refuses to fall back to t
await focusMirroredPage(page, worktreeId, pane.pageId)
const paneCountBeforeOpen = await page.getByTestId('remote-browser-pane').count()
// Act 1: the healthy path must land on the runtime, end to end.
// Act 1: server placement. The user asked for pages to live on the server, so the link must
// land on the runtime and be streamed back — nothing renders here.
await pinClientHostedPlacement(page, false)
await openLinkFromRemotePaneContextMenu(page)
await expect
.poll(
async () =>
(await readHostBrowserUrls(host, worktreeId)).filter((url) =>
(await readHostServerPlacedBrowserUrls(host, worktreeId)).filter((url) =>
url.startsWith(fixture.linkUrl)
).length,
{ timeout: 60_000, message: 'the link never opened as a browser tab on the host runtime' }
@@ -241,6 +351,12 @@ test('opens a remote pane link on the pane runtime and refuses to fall back to t
.toBe(1)
// The host's browser really fetched it; a tab record alone would not prove a load.
expect(fixture.linkLoadCount()).toBeGreaterThan(0)
await expect
.poll(() => readOwnedPageUrls(host.app, fixture.linkUrl), {
timeout: 60_000,
message: 'the runtime process never held a page for the link'
})
.toHaveLength(1)
// One more remote pane, and still nothing rendered by this machine's own browser.
await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen + 1, {
timeout: 60_000
@@ -250,34 +366,118 @@ test('opens a remote pane link on the pane runtime and refuses to fall back to t
)
expect(await readLocalBrowserViewUrls(page)).toHaveLength(0)
// Drop every tab except the pane's, so the second act drives the pane it started with.
await page.evaluate(
({ paneUrl, worktreeId }) => {
const state = window.__store?.getState()
for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) {
const pages = state?.browserPagesByWorkspace[workspace.id] ?? []
if (!pages.some((browserPage) => browserPage.url.startsWith(paneUrl))) {
state?.closeBrowserTab(workspace.id)
}
}
},
{ paneUrl: fixture.paneUrl, worktreeId }
)
// Drop every tab except the pane's, so the next act drives the pane it started with against a
// host that no longer holds the link.
await closeBrowserTabsExceptPane(page, worktreeId, fixture.paneUrl)
await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen, {
timeout: 60_000
})
await expect
.poll(
async () =>
(await readHostServerPlacedBrowserUrls(host, worktreeId)).filter((url) =>
url.startsWith(fixture.linkUrl)
).length,
{ timeout: 60_000, message: 'the runtime kept the closed browser tab' }
)
.toBe(0)
await focusMirroredPage(page, worktreeId, pane.pageId)
const linkLoadsBeforeClientAct = fixture.linkLoadCount()
// Act 2: the user moves this workspace onto their own machine while the runtime's page is
// Act 2: client-hosted placement, the default. The page is hosted by this desktop, so the
// proof of correct routing is the host's record of it, not where it painted.
await pinClientHostedPlacement(page, true)
await openLinkFromRemotePaneContextMenu(page)
await expect
.poll(
async () => (await findMirroredPage(page, worktreeId, fixture.linkUrl))?.placementKind,
{
timeout: 60_000,
message: 'the link never became a client-hosted browser page on this desktop'
}
)
.toBe('client')
// The host's own connection, unprojected: browser.tabList reads the page registry, which is
// where a client-hosted page lives.
await expect
.poll(
async () =>
(await readHostBrowserPageUrls(host.client, worktreeSelector)).filter((url) =>
url.startsWith(fixture.linkUrl)
).length,
{ timeout: 60_000, message: 'the link never became a browser page on the host runtime' }
)
.toBe(1)
const hostRows = await readHostBrowserRows(page, environmentId, worktreeId, fixture.linkUrl)
expect(hostRows).toHaveLength(1)
expect(hostRows[0]?.placementKind).toBe('client')
// The routing invariant, structurally: the host pinned this page's network to its own runtime
// when it created it, so the dev server was reached through the runtime and not through this
// machine — which CI cannot tell apart by watching the fixture, since both ends are loopback.
const executionHostKey = hostRows[0]?.executionHostKey
if (!executionHostKey) {
// Narrowed before parsing: an unpinned page would otherwise surface as a parse crash rather
// than as the missing network pin it is.
throw new Error('the host minted no network pin for the client-hosted page')
}
expect(parseBrowserNetworkExecutionHostKey(executionHostKey)).toMatchObject({
runtimeId: hostRuntimeId
})
expect(fixture.linkLoadCount()).toBeGreaterThan(linkLoadsBeforeClientAct)
// Hosted here, streamed from nowhere: this desktop holds the page and the runtime holds none.
await expect
.poll(() => readOwnedPageUrls(client!.app, fixture.linkUrl), {
timeout: 60_000,
message: 'the client-hosted guest never loaded the link on this desktop'
})
.toHaveLength(1)
expect(await readOwnedPageUrls(host.app, fixture.linkUrl)).toHaveLength(0)
await expect(page.getByTestId('remote-browser-pane')).toHaveCount(paneCountBeforeOpen)
// The store drops the tab synchronously and only then fires browser.tabClose, so the mirror
// going empty proves nothing about the host or the guest. Settle both before act 3 baselines
// them, or act 3 reads this teardown landing mid-act as its own doing.
await closeBrowserTabsExceptPane(page, worktreeId, fixture.paneUrl)
await expect
.poll(() => findMirroredPage(page, worktreeId, fixture.linkUrl), {
timeout: 60_000,
message: 'the client kept the closed link tab'
})
.toBeNull()
await expect
.poll(
async () =>
(await readHostBrowserPageUrls(host.client, worktreeSelector)).filter((url) =>
url.startsWith(fixture.linkUrl)
).length,
{ timeout: 60_000, message: 'the runtime kept the closed client-hosted page' }
)
.toBe(0)
await expect
.poll(() => readOwnedPageUrls(client!.app, fixture.linkUrl), {
timeout: 60_000,
message: 'the client-hosted guest outlived the tab that owned it'
})
.toHaveLength(0)
await focusMirroredPage(page, worktreeId, pane.pageId)
// Act 3: the user moves this workspace onto their own machine while the runtime's page is
// still on screen. Opening the link must fail in the pane, not load the runtime's dev server
// here — the client has no business serving a page for a workspace it does not run.
await page.evaluate(
({ localHostId, worktreeId }) => {
window.__store?.getState().setActiveWorktree(worktreeId, localHostId)
},
{ localHostId: LOCAL_EXECUTION_HOST_ID, worktreeId }
// `as const` keeps the host id a literal through serialization; widened to string it stops
// being an ExecutionHostId.
{ localHostId: LOCAL_EXECUTION_HOST_ID, worktreeId } as const
)
await focusMirroredPage(page, worktreeId, pane.pageId)
const hostUrlsBefore = await readHostBrowserUrls(host, worktreeId)
// The refusal is about who owns the workspace, not where pages render, so it must hold under
// the placement the user most likely has on.
await pinClientHostedPlacement(page, true)
const hostPagesBefore = await readHostBrowserPageUrls(host.client, worktreeSelector)
const linkLoadsBefore = fixture.linkLoadCount()
await openLinkFromRemotePaneContextMenu(page)
@@ -285,20 +485,20 @@ test('opens a remote pane link on the pane runtime and refuses to fall back to t
// Wait for the click to produce an outcome — refusal or a local page — so the assertion below
// reports which one happened instead of racing past a fallback that lands a moment later.
await expect
.poll(() => readLinkOpenOutcome(page, fixture.linkUrl), {
.poll(() => readLinkOpenOutcome(client!, fixture.linkUrl), {
timeout: 30_000,
message: 'the link open produced neither a refusal nor a page'
})
.not.toBe('pending')
expect(await readLinkOpenOutcome(page, fixture.linkUrl)).toBe('refused')
expect(await readLinkOpenOutcome(client, fixture.linkUrl)).toBe('refused')
// The workspace must still be the local one, or the refusal above proved nothing.
expect(
await page.evaluate(() => window.__store?.getState().activeWorkspaceExecutionHostId ?? null)
).toBe(LOCAL_EXECUTION_HOST_ID)
// Nothing rendered here, nothing new on the host, and nobody fetched the link anywhere.
expect(await readLocalBrowserViewUrls(page)).toHaveLength(0)
expect(await readHostBrowserUrls(host, worktreeId)).toEqual(hostUrlsBefore)
// Nothing new rendered here, nothing new on the host, and nobody fetched the link anywhere.
expect(await readOwnedPageUrls(client.app, fixture.linkUrl)).toHaveLength(0)
expect(await readHostBrowserPageUrls(host.client, worktreeSelector)).toEqual(hostPagesBefore)
expect(fixture.linkLoadCount()).toBe(linkLoadsBefore)
} finally {
if (client) {
@@ -5,6 +5,12 @@ import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import type { TerminalLayoutSnapshot } from '../../src/shared/terminal-tab-types'
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
import { test, expect } from './helpers/orca-app'
import {
findMarkerFrame,
readActiveScreen,
readRenderedAltScreenFrame,
type ActiveScreen
} from './helpers/alt-screen-frame'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal'
import {
@@ -124,18 +130,7 @@ async function readRendererOwnership(
}, tabId)
}
async function readStreamingFrame(
page: Page,
tabId: string,
marker: string
): Promise<number | null> {
const content = await page.evaluate((tabId) => {
const pane = window.__paneManagers?.get(tabId)?.getPanes?.()[0]
return pane?.serializeAddon?.serialize?.() ?? null
}, tabId)
return parseStreamingFrame(content, marker)
}
// Viewport-only, so it is the same projection the revealed pane is read with.
async function readMainStreamingFrame(
page: Page,
ptyId: string,
@@ -145,17 +140,47 @@ async function readMainStreamingFrame(
const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, { scrollbackRows: 0 })
return snapshot?.data ?? null
}, ptyId)
return parseStreamingFrame(content, marker)
return findMarkerFrame(content ?? '', marker)
}
function parseStreamingFrame(content: string | null, marker: string): number | null {
type RevealFrameDiagnostics = {
bufferType: ActiveScreen['bufferType'] | null
screenFrame: number | null
serializedFrame: number | null
serializedLength: number
markerOffsets: number[]
screenRows: string[]
}
// Why: if the revealed pane ever fails to converge, whether the freshest marker is on
// screen, only in normal-buffer scrollback, or absent is what separates a stalled
// renderer from stale residue left by the restore replay (STA-5208).
async function readRevealFrameDiagnostics(
page: Page,
tabId: string,
marker: string
): Promise<RevealFrameDiagnostics> {
const screen = await readActiveScreen(page, tabId)
const serialized =
(await page.evaluate((tabId) => {
// Same pane resolution as readActiveScreen, so both halves describe one pane.
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
return pane?.serializeAddon?.serialize?.() ?? null
}, tabId)) ?? ''
const prefix = `${marker} frame `
const start = content?.lastIndexOf(prefix) ?? -1
if (!content || start < 0) {
return null
const markerOffsets: number[] = []
for (let at = serialized.indexOf(prefix); at >= 0; at = serialized.indexOf(prefix, at + 1)) {
markerOffsets.push(at)
}
return {
bufferType: screen?.bufferType ?? null,
screenFrame: screen ? findMarkerFrame(screen.rows.join('\n'), marker) : null,
serializedFrame: findMarkerFrame(serialized, marker),
serializedLength: serialized.length,
markerOffsets,
screenRows: screen?.rows ?? []
}
const digits = content.slice(start + prefix.length).match(/^\d+/)?.[0]
return digits ? Number(digits) : null
}
test('repairs duplicate persisted PTY renderers before streaming tab reveal', async (// oxlint-disable-next-line no-empty-pattern -- this restart test owns its Electron launches.
@@ -231,13 +256,8 @@ test('repairs duplicate persisted PTY renderers before streaming tab reveal', as
await expect
.poll(() => getActiveTabId(secondLaunch.page), { timeout: 10_000 })
.toBe(restoredTabId)
await expect
.poll(() => readStreamingFrame(secondLaunch.page, restoredTabId, marker), {
timeout: 20_000,
message: 'Revealed renderer did not catch up to hidden authoritative output'
})
.toBeGreaterThanOrEqual(hiddenFrame)
// Ownership first: the frame is read off the single repaired pane, so the repair has
// to have settled before that read means anything.
await expect
.poll(() => readRendererOwnership(secondLaunch.page, restoredTabId), { timeout: 10_000 })
.toEqual({
@@ -247,10 +267,46 @@ test('repairs duplicate persisted PTY renderers before streaming tab reveal', as
ptyBindingCount: 1,
uniquePtyCount: 1
})
await testInfo.attach('duplicate-pty-renderer-after-reveal.png', {
body: await secondLaunch.page.screenshot(),
contentType: 'image/png'
})
let revealFailure: unknown = null
try {
await expect
.poll(() => readRenderedAltScreenFrame(secondLaunch.page, restoredTabId, marker), {
timeout: 20_000,
message: 'Revealed renderer did not catch up to hidden authoritative output'
})
.toBeGreaterThanOrEqual(hiddenFrame)
// The fixture enters the alternate buffer once at startup and repaints in place, so a
// revealed pane parked on the normal buffer is painting the TUI into scrollback.
const revealedScreen = await readActiveScreen(secondLaunch.page, restoredTabId)
expect(
revealedScreen?.bufferType,
'revealed pane was missing or painting outside the alternate buffer'
).toBe('alternate')
} catch (error) {
// Diagnostics are more page reads, so a dead page has to degrade to a note in the
// attachment rather than replacing the failure the attachment exists to explain.
revealFailure = error
const diagnostics = await readRevealFrameDiagnostics(
secondLaunch.page,
restoredTabId,
marker
).catch((diagnosticsError: unknown) => ({ diagnosticsError: String(diagnosticsError) }))
await testInfo.attach('duplicate-pty-reveal-frame-diagnostics.json', {
body: JSON.stringify(diagnostics, null, 2),
contentType: 'application/json'
})
}
// Evidence for both outcomes; a capture that fails must not become the verdict.
const screenshot = await secondLaunch.page.screenshot().catch(() => null)
if (screenshot) {
await testInfo.attach('duplicate-pty-renderer-after-reveal.png', {
body: screenshot,
contentType: 'image/png'
})
}
if (revealFailure) {
throw revealFailure
}
} finally {
tui.cleanup()
if (secondApp) {
@@ -2,6 +2,7 @@ import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { alternateScreenFixtureScript } from './alternate-screen-fixture-script'
import { test, expect } from './helpers/orca-app'
import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal'
import {
@@ -87,7 +88,7 @@ function writeHiddenFrameScript(scriptPath: string, runId: string): void {
mkdirSync(path.dirname(scriptPath), { recursive: true })
writeFileSync(
scriptPath,
`setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n`
alternateScreenFixtureScript(frames.join(''), HIDDEN_FRAME_SCRIPT_DELAY_MS)
)
}
@@ -246,59 +247,63 @@ test.describe('Hidden terminal TUI visual restore', () => {
const scriptPath = path.join(testRepoPath, `.orca-hidden-tui-visual-${runId}.mjs`)
writeHiddenFrameScript(scriptPath, runId)
await resetHiddenDebug(orcaPage)
await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath)
await resetHiddenDebug(orcaPage)
try {
await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath)
await resetHiddenDebug(orcaPage)
// Why: hidden-delivery gate contract — the bulk TUI frames must be
// withheld in main (dropped after model ingestion), not delivered and
// skipped renderer-side.
await expect
.poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), {
timeout: 10_000,
message: 'visually rich hidden TUI output was not withheld from the renderer'
})
.toBeGreaterThan(1024)
await expect
.poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), {
timeout: 10_000,
message: 'visually rich hidden TUI source did not come from headless model'
})
.toBe('headless')
// Why: hidden-delivery gate contract — the bulk TUI frames must be
// withheld in main (dropped after model ingestion), not delivered and
// skipped renderer-side.
await expect
.poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), {
timeout: 10_000,
message: 'visually rich hidden TUI output was not withheld from the renderer'
})
.toBeGreaterThan(1024)
await expect
.poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), {
timeout: 10_000,
message: 'visually rich hidden TUI source did not come from headless model'
})
.toBe('headless')
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'hidden TUI final frame did not restore when the workspace became visible'
})
.toContain(finalMarker)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'hidden TUI final frame did not restore when the workspace became visible'
})
.toContain(finalMarker)
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`Frame 024`)
expect(content).toContain('╭')
expect(content).toContain('├')
expect(content).toContain('█')
expect(content).not.toContain('Orca skipped hidden terminal output')
await expect
.poll(() => readTuiCursorState(orcaPage), {
timeout: 5_000,
message: 'restored TUI cursor stayed hidden after final frame'
})
.toMatchObject({
hidden: false,
initialized: true
})
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`Frame 024`)
expect(content).toContain('╭')
expect(content).toContain('├')
expect(content).toContain('█')
expect(content).not.toContain('Orca skipped hidden terminal output')
await expect
.poll(() => readTuiCursorState(orcaPage), {
timeout: 5_000,
message: 'restored TUI cursor stayed hidden after final frame'
})
.toMatchObject({
hidden: false,
initialized: true
})
const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('hidden-tui-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
rmSync(scriptPath, { force: true })
const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('hidden-tui-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
await sendToTerminal(orcaPage, hiddenPane.ptyId, '\x03').catch(() => undefined)
rmSync(scriptPath, { force: true })
}
})
test('keeps newer live output correct after plain hidden output restores', async ({
@@ -480,6 +485,7 @@ test.describe('Hidden terminal TUI visual restore', () => {
contentType: 'image/png'
})
} finally {
await sendToTerminal(orcaPage, hiddenPane.ptyId, '\x03').catch(() => undefined)
rmSync(scriptPath, { force: true })
}
})
+15 -7
View File
@@ -2,6 +2,7 @@ import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { alternateScreenFixtureScript } from './alternate-screen-fixture-script'
import { test, expect } from './helpers/orca-app'
import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal'
import {
@@ -18,6 +19,7 @@ import {
waitForPaneIdentitySnapshot
} from './helpers/terminal'
import { parkHiddenTabBehindDecoy, waitForTabParked } from './helpers/terminal-hidden-parking'
import { waitForPtyShellEcho } from './terminal-pty-readiness'
import { TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS } from '../../src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry'
// Why: the parking wiring registers this handle (dev/exposeStore builds only)
@@ -73,7 +75,7 @@ function writeParkedFrameScript(scriptPath: string, runId: string): void {
mkdirSync(path.dirname(scriptPath), { recursive: true })
writeFileSync(
scriptPath,
`setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n`
alternateScreenFixtureScript(frames.join(''), PARKED_FRAME_SCRIPT_DELAY_MS)
)
}
@@ -103,12 +105,7 @@ function cycleReferenceFrame(runId: string): string {
function writeCycleReferenceScript(scriptPath: string, runId: string): void {
mkdirSync(path.dirname(scriptPath), { recursive: true })
// Paint the frame once, then hold the process open so the alt-screen TUI
// stays on screen (and the parkable PTY session stays alive) across cycles.
writeFileSync(
scriptPath,
`process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n`
)
writeFileSync(scriptPath, alternateScreenFixtureScript(cycleReferenceFrame(runId)))
}
// Why: serialize() re-emits the buffer with cursor-restore trailer sequences
@@ -335,6 +332,17 @@ test.describe('Terminal hidden view parking', () => {
expect(content).toContain('█')
expect(content).not.toContain('Orca skipped hidden terminal output')
// Why: the fixture TUI still owns the PTY foreground after the reveal, so
// interrupt it and wait for the shell to take input back before probing.
await sendToTerminal(orcaPage, tabAPtyId, '\x03')
// Why rethrow: the readiness failure reads as a dead shell, but the only new
// dependency here is Ctrl-C reaching the foreground TUI (ConPTY translates it).
await waitForPtyShellEcho(orcaPage, tabAPtyId, 15_000).catch((error: unknown) => {
throw new Error(
`Ctrl-C did not hand the PTY back from the fixture TUI: ${error instanceof Error ? error.message : String(error)}`
)
})
// Why: the typed marker only appears joined in command *output*, so this
// proves the revealed terminal accepts input end-to-end, not just echo.
const typedMarker = `PARKED_TYPED_OK_${runId}`
@@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'
import type { Page } from '@stablyai/playwright-test'
import { alternateScreenFixtureScript } from './alternate-screen-fixture-script'
import { expect, test } from './helpers/orca-app'
import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal'
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
import {
ensureTerminalVisible,
@@ -136,48 +138,56 @@ test('restores and opens an OSC 8 link after its terminal is cold-parked', async
const label = `#${randomUUID().slice(0, 6)}`
const url = `https://example.com/orca-osc8-${randomUUID()}`
const linkedOutput = `\x1b[?1049h\x1b[2J\x1b[H\x1b]8;id=cold-park;${url}\x1b\\${label}\x1b]8;;\x1b\\\n`
await sendToTerminal(
orcaPage,
ptyId,
`${nodeTerminalCommand(['-e', `process.stdout.write(${JSON.stringify(linkedOutput)})`])}\r`
)
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
const baselineProbe = await locateLink(orcaPage, label)
await orcaPage.mouse.move(baselineProbe.clientX, baselineProbe.clientY)
await expect
.poll(() => readLinkState(orcaPage, tabId, label))
.toMatchObject({
bufferType: 'alternate',
serializedUri: true,
underlined: true,
uri: url
})
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
parkDelayMs: PARKING_DELAY_MS
// Why staged rather than `node -e`: PowerShell mangles the escapes (#8521), and it
// keeps the label out of the command line so the readiness poll below cannot be
// satisfied by the shell's own echo. `staged.command` is bypassed because it runs a
// bare `node`; nodeTerminalCommand pins process.execPath for Windows CI's PATH.
const staged = stageNodeScriptForTerminal(alternateScreenFixtureScript(linkedOutput), {
prefix: 'orca-osc8-cold-park'
})
await activateTerminalTab(orcaPage, tabId)
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
try {
await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([staged.scriptPath])}\r`)
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
const restoredProbe = await locateLink(orcaPage, label)
await orcaPage.mouse.move(restoredProbe.clientX, restoredProbe.clientY)
await expect
.poll(() => readLinkState(orcaPage, tabId, label))
.toMatchObject({
bufferType: 'alternate',
serializedUri: true,
underlined: true,
uri: url
const baselineProbe = await locateLink(orcaPage, label)
await orcaPage.mouse.move(baselineProbe.clientX, baselineProbe.clientY)
await expect
.poll(() => readLinkState(orcaPage, tabId, label))
.toMatchObject({
bufferType: 'alternate',
serializedUri: true,
underlined: true,
uri: url
})
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
parkDelayMs: PARKING_DELAY_MS
})
await activateTerminalTab(orcaPage, tabId)
await expect.poll(() => getTerminalContent(orcaPage, 4_000)).toContain(label)
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const modifier = isMac ? 'Meta' : 'Control'
await orcaPage.keyboard.down(modifier)
await orcaPage.mouse.down()
await orcaPage.mouse.up()
await orcaPage.keyboard.up(modifier)
await expect
.poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).some((tab) => tab.url === url))
.toBe(true)
const restoredProbe = await locateLink(orcaPage, label)
await orcaPage.mouse.move(restoredProbe.clientX, restoredProbe.clientY)
await expect
.poll(() => readLinkState(orcaPage, tabId, label))
.toMatchObject({
bufferType: 'alternate',
serializedUri: true,
underlined: true,
uri: url
})
const isMac = await orcaPage.evaluate(() => navigator.userAgent.includes('Mac'))
const modifier = isMac ? 'Meta' : 'Control'
await orcaPage.keyboard.down(modifier)
await orcaPage.mouse.down()
await orcaPage.mouse.up()
await orcaPage.keyboard.up(modifier)
await expect
.poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).some((tab) => tab.url === url))
.toBe(true)
} finally {
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
staged.cleanup()
}
})
@@ -1,7 +1,14 @@
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
const CHECKPOINT_ERROR = 'Renderer shutdown checkpoint was not completed.'
// Only the prefix is contract: the checkpoint error appends the swallowed persist
// cause (STA-5505), whose wording belongs to whatever threw.
const CHECKPOINT_ERROR_PREFIX = 'Renderer shutdown checkpoint was not completed: '
// The null url is injected, never hydrated: the desktop session schema drops such a row, and
// no other arrival path carries browserUrlHistory at all (paired web reads it unvalidated but
// has no producer — STA-5668 follow-up). It is just a deterministic snapshot-build failure.
const CORRUPT_HISTORY_ENTRY = { url: null, title: 'corrupt persisted history', lastVisitedAt: 0 }
test('recovers update install from a corrupt clean session but preserves dirty drafts', async ({
orcaPage,
@@ -15,16 +22,14 @@ test('recovers update install from a corrupt clean session but preserves dirty d
})
const dirtyResult = await orcaPage.evaluate(
async ({ filePath, worktreeId }) => {
async ({ filePath, worktreeId, corruptEntry }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const originalHistory = state.browserUrlHistory
state.browserUrlHistory = [
{ url: null, title: 'corrupt persisted history', lastVisitedAt: 0 }
] as unknown as typeof state.browserUrlHistory
state.browserUrlHistory = [corruptEntry] as unknown as typeof state.browserUrlHistory
const fileId = state.openFile({
filePath,
relativePath: 'checkpoint-draft.txt',
@@ -48,22 +53,24 @@ test('recovers update install from a corrupt clean session but preserves dirty d
},
{
filePath: path.join(testRepoPath, 'checkpoint-draft.txt'),
worktreeId: await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId ?? '')
worktreeId: await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId ?? ''),
corruptEntry: CORRUPT_HISTORY_ENTRY
}
)
expect(dirtyResult).toBe(CHECKPOINT_ERROR)
expect(dirtyResult).toContain(CHECKPOINT_ERROR_PREFIX)
// Pin the cause to the corrupt row, not just any named failure; only the member name
// survives V8 rewording of "Cannot read properties of null".
expect(dirtyResult).toContain('toLowerCase')
const cleanResult = await orcaPage.evaluate(async () => {
const cleanResult = await orcaPage.evaluate(async (corruptEntry) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const originalHistory = state.browserUrlHistory
state.browserUrlHistory = [
{ url: null, title: 'corrupt persisted history', lastVisitedAt: 0 }
] as unknown as typeof state.browserUrlHistory
state.browserUrlHistory = [corruptEntry] as unknown as typeof state.browserUrlHistory
try {
await window.api.updater.quitAndInstall()
return 'continued'
@@ -72,7 +79,7 @@ test('recovers update install from a corrupt clean session but preserves dirty d
} finally {
state.browserUrlHistory = originalHistory
}
})
}, CORRUPT_HISTORY_ENTRY)
expect(cleanResult).toBe('continued')
expect(fallbackLogs).toHaveLength(1)
+11 -1
View File
@@ -78,7 +78,17 @@ async function seedVirtualizedManualWorktrees(page: Page): Promise<{
...state.worktreesByRepo,
[repoId]: [...worktrees, ...seededWorktrees]
},
updateWorktreesMeta: async (updatesByWorktreeId) => {
// Stands in for the real action only to skip its IPC persistence tail: these 60 rows
// are store-only, so persisting them would fail and refetch them away mid-drag.
updateWorktreesMeta: async (batchUpdates) => {
if (batchUpdates.length === 0) {
return
}
// executionHostId is ignored on purpose: every seeded row is host-less, which the
// real action treats as local (worktree-meta-host-match.ts).
const updatesByWorktreeId = new Map(
batchUpdates.map((batchUpdate) => [batchUpdate.worktreeId, batchUpdate.updates])
)
store.setState((current) => ({
sortEpoch: current.sortEpoch + 1,
worktreesByRepo: Object.fromEntries(
+43 -15
View File
@@ -1,4 +1,4 @@
import type { Page } from '@stablyai/playwright-test'
import type { Locator, Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
@@ -132,6 +132,23 @@ async function selectRemoteHost(page: Page, useKeyboard = false): Promise<void>
await filterTrigger(page).click()
}
async function openComposerFromTypedName(page: Page): Promise<Locator> {
await openPalette(page)
const input = palette(page).getByPlaceholder(SEARCH_PLACEHOLDER)
await input.fill(`cmd-j-enter-${Date.now()}`)
await expect(palette(page).locator('[cmdk-item][data-value="__create_worktree__"]')).toBeVisible()
await input.press('Enter')
const createDialog = page.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(createDialog).toBeVisible()
// Why assert focus: the composer auto-focuses the name field, so Escape always
// lands on an input the user never chose. A page-style "blur the field first"
// handler reachable from here would silently cost a second press.
await expect(createDialog.locator('[data-workspace-name-input="true"]')).toBeFocused()
return createDialog
}
test.describe('Worktree jump-palette filters', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
@@ -182,22 +199,33 @@ test.describe('Worktree jump-palette filters', () => {
})
test('pressing Enter creates a worktree from a typed name', async ({ orcaPage }) => {
await openPalette(orcaPage)
const input = palette(orcaPage).getByPlaceholder(SEARCH_PLACEHOLDER)
await input.fill(`cmd-j-enter-${Date.now()}`)
await expect(
palette(orcaPage).locator('[cmdk-item][data-value="__create_worktree__"]')
).toBeVisible()
const createDialog = await openComposerFromTypedName(orcaPage)
await input.press('Enter')
const createDialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i })
await expect(createDialog).toBeVisible()
// Why assert focus first: the composer auto-focuses the name field, so Escape
// always lands on an input the user never chose. A page-style "blur the field
// first" handler here would silently cost a second press.
await expect(createDialog.locator('[data-workspace-name-input="true"]')).toBeFocused()
await orcaPage.keyboard.press('Escape')
await expect(createDialog).toBeHidden()
})
test('Escape closes the composer opened over the Automations page', async ({ orcaPage }) => {
// Why this view: Cmd+J has no view guard, and a page mounted under the palette
// keeps its own capture-phase Escape listener registered. Window capture runs
// before Radix's document capture, so a preventDefault there vetoes dismissal.
await orcaPage.evaluate(() => window.__store?.getState().openAutomationsPage())
const automationsHeading = orcaPage.getByRole('heading', { name: 'Automations', level: 1 })
await expect(automationsHeading).toBeVisible()
const createDialog = await openComposerFromTypedName(orcaPage)
await orcaPage.keyboard.press('Escape')
await expect(createDialog).toBeHidden()
// The page declined the press rather than consuming it, so it is still open.
await expect(automationsHeading).toBeVisible()
// Why a second press: with nothing layered above, the real page chrome must not
// trip the overlay check, or Escape would never close Automations again.
await orcaPage.keyboard.press('Escape')
await expect(automationsHeading).toBeHidden()
})
})