Stop hidden terminal panes from exhausting renderer memory (hidden-view parking, byte mode, default off)

Extracts the self-contained "hidden-view parking" slice from PR #7214
(orca-performance) as a standalone fix for the Windows renderer OOM/hang
under multi-agent load (#7175 / STA-476): long-hidden terminal panes
unmount after a 30s hysteresis while a pane-less byte watcher keeps
bell/title/agent-completion side effects alive; reveal rides the existing
tab-move reattach path.

Byte mode only: no main-process side-effect emitter ships here, so
constants.ts pins terminalMainSideEffectAuthority / terminalHiddenDeliveryGate /
terminalModelQueryAuthority to false (the single intentional divergence from
#7214, reconciled by taking #7214's values when its emitter lands). The
parking kill switch terminalHiddenViewParking also ships default-OFF
(opt-in) until the memory win is confirmed in a soak.

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-04 14:26:39 -07:00
co-authored by Jinwoo-H Orca
parent ac8d365296
commit ff5833dd4f
45 changed files with 6458 additions and 625 deletions
+35 -35
View File
@@ -51,6 +51,14 @@ type OrcaTestFixtures = {
// Why: most E2E specs need a ready project before assertions start. Golden
// first-run specs opt out so they can prove the zero-project onboarding path.
seedTestRepo: boolean
// Why: spec-scoped launch env. Mutating process.env at spec module scope
// leaks into other specs when a worker reloads files without replaying the
// first spec's afterAll; per-test launch env cannot leak.
orcaAppExtraEnv: Record<string, string>
// Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for
// memory benchmarks). Prepended before the main entry so Electron forwards
// them to Chromium without affecting other specs' launches.
orcaAppExtraArgs: string[]
// Why: a few IPC repro specs need to launch the Electron app with a scoped
// PATH/token environment. Keep this fixture-owned so tests never mutate the
// developer's shell or already-running Orca instance.
@@ -203,7 +211,11 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
],
// Test-scoped: one Electron app per test
electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => {
electronApp: async (
{ dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs },
provideFixture,
testInfo
) => {
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-'))
@@ -240,7 +252,7 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
mkdirSync(recordVideoDir, { recursive: true })
}
const app = await electron.launch({
args: getOrcaElectronLaunchArgs(mainPath, headful),
args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)],
...(slowMo > 0 ? { slowMo } : {}),
...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}),
// Why: keep NODE_ENV=development so window.__store is exposed and
@@ -265,7 +277,8 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
!cleanEnv.ORCA_RELAY_PATH
? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') }
: {}),
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }),
...orcaAppExtraEnv
}
})
forwardElectronProcessLogs(app, testInfo)
@@ -281,6 +294,8 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
dismissOnboarding: [true, { option: true }],
seedTestRepo: [true, { option: true }],
launchEnv: [{}, { option: true }],
orcaAppExtraEnv: [{}, { option: true }],
orcaAppExtraArgs: [[], { option: true }],
// Test-scoped: grab the first BrowserWindow, add the test repo, and wait
// until the session is fully ready with a worktree active.
@@ -314,38 +329,23 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
await window.api.repos.add({ path: repoPath })
}, repoPath)
// Fetch repos in the renderer store so it picks up the new repo, then opt
// this disposable repo into showing external worktrees.
// Why: repos.add() fires a repos:changed echo that triggers a *concurrent*
// fetchRepos() in the renderer; the store's generation guard can then drop
// this awaited fetch's result, leaving `repos` briefly stale. Poll the
// public fetch path until the repo lands instead of asserting on the first
// tick (mirrors the seeded-worktree poll below). updateRepo is idempotent,
// so running it once the repo appears is safe across poll ticks.
await playwrightExpect
.poll(
() =>
page.evaluate(async (repoPath) => {
const store = window.__store
if (!store) {
return false
}
await store.getState().fetchRepos()
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
if (!repo) {
return false
}
// Why: the fixture deliberately creates external Git worktrees. New
// repos hide those by default after the visibility rollout.
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
return true
}, repoPath),
{
timeout: 30_000,
message: `Expected e2e repo to be loaded: ${repoPath}`
}
)
.toBe(true)
// Fetch repos in the renderer store so it picks up the new repo
await page.evaluate(async (repoPath) => {
const store = window.__store
if (!store) {
return
}
await store.getState().fetchRepos()
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
if (!repo) {
throw new Error(`Expected e2e repo to be loaded: ${repoPath}`)
}
// Why: the fixture deliberately creates external Git worktrees. New
// repos hide those by default after the visibility rollout, so opt this
// disposable repo into showing them before specs assert on worktree state.
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
}, repoPath)
// Wait for the repo to appear and fetch its worktrees
await page.evaluate(async () => {
@@ -0,0 +1,570 @@
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 { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
getWorktreeTabs,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
// Why: the parking wiring registers this handle (dev/exposeStore builds only)
// so tests can detect that hidden-view parking is compiled in and which delay
// override the app actually applied.
type ParkingDebugWindow = Window & {
__terminalParkingDebug?: {
parkDelayMs?: number
}
}
// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain
// window. The fast-park override must be scoped to THIS spec's app launches —
// mutating process.env at module scope leaked into later specs when a worker
// reloaded files without replaying this file's afterAll.
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
})
const PARKED_FRAME_SCRIPT_DELAY_MS = 750
const PARKED_FRAME_COUNT = 25
function parkedTuiFrame(runId: string, frame: number): string {
const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}`
const rows = [
'╭────────────────────────────────────────────────────────────────────╮',
`│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`,
'├──────────────┬──────────────────────┬──────────────────────────────┤',
`│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`,
`│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`,
`│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`,
'╰──────────────┴──────────────────────┴──────────────────────────────╯',
`PARKED_RESTORE_FINAL_${runId}_${frame}`
]
return [
'\x1b[?2026h',
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[10;18H\x1b[?25h',
'\x1b[?2026l'
].join('')
}
function writeParkedFrameScript(scriptPath: string, runId: string): void {
const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) =>
parkedTuiFrame(runId, frame)
)
mkdirSync(path.dirname(scriptPath), { recursive: true })
writeFileSync(
scriptPath,
`setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n`
)
}
// Deterministic static alt-screen frame for the park/reveal cycle test: rich
// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot
// restore, painted once and held so the on-screen content is stable across
// cycles. No spinner/progress churn — the frame must be byte-identical every
// reveal so drift is detectable.
function cycleReferenceFrame(runId: string): string {
const rows = [
'╭──────────────────────────────────────────────────────────╮',
`│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`,
'├───────────────┬──────────────────────────────────────────┤',
`│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`,
`│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`,
'╰───────────────┴──────────────────────────────────────────╯',
`CYCLE_REFERENCE_${runId}`
]
return [
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[?25h'
].join('')
}
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`
)
}
// Why: serialize() re-emits the buffer with cursor-restore trailer sequences
// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any
// visible change. Compare the CONTENT rows, not the trailer — strip trailing
// control sequences and normalize whitespace-only tail lines.
function terminalContentRows(serialized: string): string[] {
// eslint-disable-next-line no-control-regex
const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
return stripped
.split(/\r?\n/)
.map((line) => line.replace(/\s+$/, ''))
.filter((line) => line.length > 0)
}
async function readParkingWiring(
page: Page
): Promise<{ present: boolean; parkDelayMs: number | null }> {
return page.evaluate(() => {
const debug = (window as ParkingDebugWindow).__terminalParkingDebug
return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null }
})
}
// Why: the spec lands ahead of the feature wiring. Skip (rather than fail)
// when the app under test does not expose the parking debug handle so this
// file is safe to merge in any order with the wiring branch.
async function skipUnlessParkingWired(page: Page): Promise<void> {
const deadline = Date.now() + 2_000
let wiring = await readParkingWiring(page)
while (!wiring.present && Date.now() < deadline) {
await page.waitForTimeout(250)
wiring = await readParkingWiring(page)
}
test.skip(
!wiring.present,
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
)
}
// Why: this standalone extraction ships terminalHiddenViewParking default-OFF
// (opt-in until the memory win is soak-confirmed); PR #7214 later flips the
// default to true, making this enable a no-op. Same mutation path as the
// kill-switch test in terminal-parked-memory.spec.ts.
async function enableTerminalParking(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parking spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: true })
})
}
type TerminalTabViewState = {
hasManager: boolean
paneCount: number
}
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
return {
hasManager: manager !== undefined,
paneCount: manager?.getPanes?.().length ?? 0
}
}, tabId)
}
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a
// missing manager is the observable signal that the tab's xterm was parked.
async function waitForTabParked(page: Page, tabId: string): Promise<number> {
const parkWaitStartedAt = Date.now()
await expect
.poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, {
timeout: Math.max(20_000, PARKING_DELAY_MS * 10),
message: `terminal tab ${tabId} did not park (pane manager still mounted)`
})
.toBe(false)
return Date.now() - parkWaitStartedAt
}
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
await page.evaluate((targetTabId) => {
const store = window.__store
if (!store) {
throw new Error('activateTerminalTab: window.__store is unavailable')
}
const state = store.getState()
state.setActiveTabType('terminal')
state.setActiveTab(targetTabId)
}, tabId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: `terminal tab ${tabId} did not become active`
})
.toBe(tabId)
}
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<string> {
const tabId = await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('createActiveTerminalTab: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'newly created terminal tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
await waitForPaneIdentitySnapshot(page, 1)
return tabId
}
async function getUnreadTerminalTabIds(page: Page): Promise<string[]> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return []
}
return Object.keys(store.getState().unreadTerminalTabs)
})
}
async function isWorktreeUnread(page: Page, worktreeId: string): Promise<boolean> {
return page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
return false
}
const worktree = Object.values(store.getState().worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId)
return worktree?.isUnread === true
}, worktreeId)
}
async function getTerminalTabTitle(
page: Page,
worktreeId: string,
tabId: string
): Promise<string | null> {
const tabs = await getWorktreeTabs(page, worktreeId)
return tabs.find((tab) => tab.id === tabId)?.title ?? null
}
async function hasPendingStartupCommand(page: Page, tabId: string): Promise<boolean> {
return page.evaluate((tabId) => {
const store = window.__store
if (!store) {
return false
}
return store.getState().pendingStartupByTabId[tabId] !== undefined
}, tabId)
}
type ParkableTabSetup = {
worktreeId: string
tabAId: string
tabAPtyId: string
}
// Why: every scenario starts from the same shape — tab A live in the active
// worktree; callers then create more tabs on top so tab A goes hidden.
async function setUpParkableTabA(page: Page): Promise<ParkableTabSetup> {
const worktreeId = await waitForActiveWorktree(page)
await skipUnlessParkingWired(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1)
const tabAPtyId = tabASnapshot.panes[0]?.ptyId
if (!tabAPtyId) {
throw new Error('parking spec tab A did not bind a PTY')
}
return {
worktreeId,
tabAId: tabASnapshot.tabId,
tabAPtyId
}
}
test.describe('Terminal hidden view parking', () => {
test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
const runId = randomUUID()
const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}`
const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`)
writeParkedFrameScript(scriptPath, runId)
try {
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'rich TUI final frame did not render while tab A was visible'
})
.toContain(finalMarker)
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId)
const wiring = await readParkingWiring(orcaPage)
testInfo.annotations.push({
type: 'terminal-parking',
description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}`
})
// Why: parking must be scoped to the hidden tab — the visible tab keeps
// a live pane manager and xterm.
const tabBState = await readTerminalTabViewState(orcaPage, tabBId)
expect(tabBState.hasManager).toBe(true)
expect(tabBState.paneCount).toBeGreaterThan(0)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealedSnapshot.tabId).toBe(tabAId)
// Why: parking only tears down the renderer view; the PTY session must
// survive so reveal reattaches to the same shell.
expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'parked rich TUI frame did not restore when the tab was revealed'
})
.toContain(finalMarker)
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`)
expect(content).toContain('╭')
expect(content).toContain('├')
expect(content).toContain('█')
expect(content).not.toContain('Orca skipped hidden terminal output')
// 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}`
const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')`
await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'revealed terminal did not execute and display typed input'
})
.toContain(typedMarker)
const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('parked-tab-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
test('keeps bell and title side effects live while parked', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
await createActiveTerminalTab(orcaPage, worktreeId)
await waitForTabParked(orcaPage, tabAId)
const runId = randomUUID()
const parkedTitle = `Parked side effects ${runId}`
const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}`
// Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL
// must not count as a bell), then a content marker for the reveal check.
// The 30s keep-alive stops the shell prompt from overwriting the title
// before the store assertion lands.
const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n`
const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)`
await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`)
await expect
.poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), {
timeout: 10_000,
message: 'parked OSC 0 title did not update the tab title in the store'
})
.toBe(parkedTitle)
await expect
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), {
timeout: 10_000,
message: 'parked BEL did not mark the terminal tab unread'
})
.toBe(true)
await expect
.poll(() => isWorktreeUnread(orcaPage, worktreeId), {
timeout: 10_000,
message: 'parked BEL did not mark the worktree unread'
})
.toBe(true)
// Why: side effects must come from the pane-less watcher — the burst must
// not have woken the parked view back up.
expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'parked side-effect marker did not restore when the tab was revealed'
})
.toContain(marker)
})
test('does not park excluded tabs', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId } = setup
// Tab C: parking-excluded because it has a pending startup command. Queue
// it after the pane mounted so the mount-time consume cannot drain it.
const tabCId = await createActiveTerminalTab(orcaPage, worktreeId)
await orcaPage.evaluate((tabId) => {
const store = window.__store
if (!store) {
throw new Error('parking exclusion spec: window.__store is unavailable')
}
store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' })
}, tabCId)
expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true)
// Tab B on top hides both A and C.
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
await expect
.poll(() => getActiveTabId(orcaPage), {
timeout: 5_000,
message: 'tab B did not stay active while waiting on the parking window'
})
.toBe(tabBId)
// Why: tab A parking proves the machinery ran past the delay in this app
// instance, so the tab C assertion below is not vacuously green.
await waitForTabParked(orcaPage, tabAId)
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
// Premise guard: nothing consumed the pending startup while hidden.
expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true)
const tabCState = await readTerminalTabViewState(orcaPage, tabCId)
expect(tabCState.hasManager).toBe(true)
expect(tabCState.paneCount).toBeGreaterThan(0)
})
// Drives 25 deterministic park→reveal cycles on a static rich TUI frame and
// asserts every reveal reproduces the SAME content the tab showed while it was
// continuously visible (the never-parked reference). This is the field-garble
// guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator
// snapshot restore + PTY reattach path the fuzz suites model in isolation, and
// fails if any single cycle — or accumulated drift across 25 — garbles a cell.
test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
const runId = randomUUID()
const marker = `CYCLE_REFERENCE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`)
writeCycleReferenceScript(scriptPath, runId)
try {
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'cycle reference frame did not render while tab A was visible'
})
.toContain(marker)
// Tab B stays visible whenever tab A is parked; toggling the active tab
// between them is the deterministic hide/reveal driver.
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
// One park/reveal cycle to run the frame through the snapshot restore for a
// baseline. Why not compare against the visible-before-park content: an
// alt-screen restore deliberately drops the normal-buffer scrollback
// (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the
// pre-park serialize carries the shell command echo the restore correctly
// omits — that is contract, not garble. Baselining after one reveal makes
// both sides pass through identical machinery, so any later diff is drift.
const runOneParkRevealCycle = async (cycle: number): Promise<string[]> => {
await activateTerminalTab(orcaPage, tabBId)
await waitForTabParked(orcaPage, tabAId)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: `cycle ${cycle}: reference frame did not restore on reveal`
})
.toContain(marker)
const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000))
// Garble sentinel: the hidden-skip banner must never appear.
expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output')
return rows
}
const referenceRows = await runOneParkRevealCycle(0)
expect(referenceRows.join('\n')).toContain(marker)
expect(referenceRows.join('\n')).toContain('╭')
// waitForTabParked (inside runOneParkRevealCycle) throws if the tab never
// parked, so reaching here means the machinery ran every cycle — no
// separate premise guard needed for a vacuous-green check.
const CYCLES = 25
const mismatches: string[] = []
for (let cycle = 1; cycle < CYCLES; cycle++) {
const rows = await runOneParkRevealCycle(cycle)
if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) {
mismatches.push(
`cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}`
)
}
}
testInfo.annotations.push({
type: 'terminal-parking-cycles',
description: `cycles=${CYCLES} mismatches=${mismatches.length}`
})
expect(
mismatches,
`park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}`
).toEqual([])
const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('park-reveal-25-cycles-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
})
+373
View File
@@ -0,0 +1,373 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
// Why: production cold-park hysteresis is 30s. The fast-park env override is
// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as
// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs.
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) },
// Why: without this switch Chromium quantizes performance.memory and only
// refreshes it every ~20 minutes, so both scenarios report the same stale
// launch-time bucket instead of a comparable heap figure.
orcaAppExtraArgs: ['--enable-precise-memory-info']
})
// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit
// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS
// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the
// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden
// past hotRetainMs before the retain-count limit is even consulted. So all 8
// park without needing 14 tabs or extra policy knobs.
const SCROLLBACK_TAB_COUNT = 8
const SCROLLBACK_LINE_COUNT = 3000
const PARK_SETTLE_MS = 2_000
const HEAP_SAMPLE_COUNT = 5
const HEAP_SAMPLE_INTERVAL_MS = 250
// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of
// scrollback each, then waits out the parking window — well past the default
// 120s per-test budget.
const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000
// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes
// each xterm hold realistic narrow+wide buffer rows, so released parked-tab
// memory reflects real agent output rather than uniform filler.
function writeScrollbackFillScript(scriptPath: string, runId: string): void {
const script = [
`const tabIndex = process.argv[2] ?? '0'`,
`const wide = '統合端末記憶計測'`,
`const emoji = ['🟢', '🟡', '🔵', '🟣']`,
`const lines = []`,
`for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`,
` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`,
` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`,
` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`,
`}`,
`process.stdout.write(lines.join('\\n') + '\\n')`,
`process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')`
].join('\n')
writeFileSync(scriptPath, `${script}\n`)
}
// Why: the spec lands ahead of the feature wiring in some merge orders. Skip
// (rather than fail) when the app under test does not expose the parking
// debug handle, mirroring terminal-hidden-view-parking.spec.ts.
async function skipUnlessParkingWired(page: Page): Promise<void> {
const deadline = Date.now() + 2_000
let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
while (!present && Date.now() < deadline) {
await page.waitForTimeout(250)
present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
}
test.skip(
!present,
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
)
}
type TerminalTabViewState = {
hasManager: boolean
paneCount: number
}
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so
// a missing manager is the observable signal that the tab's xterm was parked.
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
return {
hasManager: manager !== undefined,
paneCount: manager?.getPanes?.().length ?? 0
}
}, tabId)
}
async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise<number> {
return page.evaluate(
(tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length,
tabIds
)
}
async function waitForTabsParked(page: Page, tabIds: string[]): Promise<void> {
await expect
.poll(() => countMountedPaneManagers(page, tabIds), {
timeout: Math.max(30_000, PARKING_DELAY_MS * 10),
message: 'hidden scrollback tabs did not all park (pane managers still mounted)'
})
.toBe(0)
}
type ScrollbackTab = {
tabId: string
ptyId: string
}
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<ScrollbackTab> {
const tabId = await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('createActiveTerminalTab: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'newly created terminal tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
const snapshot = await waitForPaneIdentitySnapshot(page, 1)
const ptyId = snapshot.panes[0]?.ptyId
if (snapshot.tabId !== tabId || !ptyId) {
throw new Error('createActiveTerminalTab: new tab did not bind a PTY')
}
return { tabId, ptyId }
}
async function fillActiveTerminalWithScrollback(
page: Page,
ptyId: string,
scriptPath: string,
tabIndex: number,
runId: string
): Promise<void> {
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`)
await expect
.poll(() => getTerminalContent(page, 4_000), {
timeout: 30_000,
message: `scrollback fill marker for tab ${tabIndex} did not render`
})
.toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`)
}
type ScrollbackTabSetup = {
worktreeId: string
scrollbackTabs: ScrollbackTab[]
}
// Why: each tab generates its scrollback while visible, so every xterm holds
// the full buffer before going hidden — the hidden-delivery gate never gets a
// chance to drop the output the memory comparison depends on.
async function setUpScrollbackTabs(
page: Page,
scriptPath: string,
runId: string
): Promise<ScrollbackTabSetup> {
const worktreeId = await waitForActiveWorktree(page)
await skipUnlessParkingWired(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1)
const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
if (!baselinePtyId) {
throw new Error('parked memory spec: baseline terminal tab did not bind a PTY')
}
const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId)
for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) {
const tab = await createActiveTerminalTab(page, worktreeId)
scrollbackTabs.push(tab)
await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId)
}
return { worktreeId, scrollbackTabs }
}
type ParkedMemoryMetrics = {
heapUsedMB: number
liveTerminals: number
livePaneManagers: number
}
// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best
// effort) and take the min of several settled samples — the min reflects
// retained heap instead of allocation noise between collections. Note xterm
// buffer rows are typed-array backing stores outside the V8 heap, so the
// liveTerminals/livePaneManagers counts are the strong release signal and the
// heap figure tracks only the on-heap share.
async function sampleParkedMemoryMetrics(page: Page): Promise<ParkedMemoryMetrics> {
await page.waitForTimeout(PARK_SETTLE_MS)
try {
const session = await page.context().newCDPSession(page)
await session.send('HeapProfiler.collectGarbage')
await session.detach()
} catch {
// GC over CDP is a measurement-fidelity improvement, not a gate.
}
let minHeapBytes: number | null = null
for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) {
const heapBytes = await page.evaluate(() => {
const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory
return memory?.usedJSHeapSize ?? null
})
if (heapBytes !== null) {
minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes)
}
await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS)
}
if (minHeapBytes === null) {
throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable')
}
const liveCounts = await page.evaluate(() => ({
liveTerminals: document.querySelectorAll('.xterm').length,
livePaneManagers: window.__paneManagers?.size ?? 0
}))
return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts }
}
function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string {
return [
`panes=${SCROLLBACK_TAB_COUNT}`,
`parkedTabs=${parkedTabs}`,
`heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`,
`liveTerminals=${metrics.liveTerminals}`,
`livePaneManagers=${metrics.livePaneManagers}`
].join(' ')
}
// Why: this standalone extraction ships terminalHiddenViewParking default-OFF
// (opt-in until the memory win is soak-confirmed); PR #7214 later flips the
// default to true, making this enable a no-op. Same mutation path as the
// kill-switch test in terminal-parked-memory.spec.ts.
async function enableTerminalParking(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parking spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: true })
})
}
test.describe('Terminal parked memory', () => {
test('releases renderer terminal memory when hidden tabs park', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId)
try {
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
// A fresh 9th tab hides all 8 scrollback tabs.
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
await waitForTabsParked(
orcaPage,
scrollbackTabs.map((tab) => tab.tabId)
)
const metrics = await sampleParkedMemoryMetrics(orcaPage)
testInfo.annotations.push({
type: 'opencode-parked-memory',
description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length)
})
// Structural assertions: all 8 parked (managers gone), and the only
// live xterm/pane manager belongs to the visible tab.
for (const tab of scrollbackTabs) {
expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false)
}
const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId)
expect(visibleState.hasManager).toBe(true)
expect(visibleState.paneCount).toBeGreaterThan(0)
// Why: design invariant 5 — renderer terminal views scale with visible
// panes, so parked tabs must leave no xterm DOM behind.
expect(metrics.liveTerminals).toBe(visibleState.paneCount)
expect(metrics.livePaneManagers).toBe(1)
} finally {
rmSync(scriptPath, { force: true })
}
})
test('retains terminal views when parking is disabled', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
// Why: settings.terminalHiddenViewParking === false is the design-doc
// kill switch. updateSettings persists it through window.api.settings.set
// and updates the store slice the cold-park hook subscribes to — the same
// mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env
// wiring is needed.
await orcaPage.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parked memory spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: false })
})
await expect
.poll(
() =>
orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking),
{ timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' }
)
.toBe(false)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId)
try {
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId)
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
// Why: with parking enabled these tabs park within ~1x the collapsed
// delay (the first test proves the machinery in this app build), so
// surviving 3x the delay shows the kill switch held.
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT)
const metrics = await sampleParkedMemoryMetrics(orcaPage)
testInfo.annotations.push({
type: 'opencode-parked-memory-disabled',
description: formatParkedMemoryAnnotation(metrics, 0)
})
// Structural assertions: every hidden tab keeps its pane manager and
// xterm; nothing parked even after the settle + sampling window.
for (const tab of scrollbackTabs) {
const state = await readTerminalTabViewState(orcaPage, tab.tabId)
expect(state.hasManager).toBe(true)
expect(state.paneCount).toBeGreaterThan(0)
}
expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true)
expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1)
expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1)
} finally {
rmSync(scriptPath, { force: true })
}
})
})
@@ -0,0 +1,220 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
type SleepWakeTerminalDebug = {
activeTabId: string | null
activeWorktreeId: string | null
tabs: {
id: string
ptyId?: string
generation?: number
pendingActivationSpawn?: boolean | number
}[]
ptyIdsByTabId: Record<string, string[]>
ptyIdsByLeafIdByTabId: Record<string, Record<string, string>>
}
async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise<void> {
await page.evaluate(async (id) => {
const store = window.__store
if (!store) {
throw new Error('store unavailable')
}
const state = store.getState()
await state.shutdownWorktreeBrowsers(id)
await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true })
}, worktreeId)
}
async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise<number> {
return page.evaluate((id) => {
const store = window.__store
if (!store) {
return 0
}
const state = store.getState()
const tabs = state.tabsByWorktree[id] ?? []
return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0)
}, worktreeId)
}
async function readSleepWakeTerminalDebug(
page: Page,
worktreeId: string
): Promise<SleepWakeTerminalDebug> {
return page.evaluate((id) => {
const store = window.__store
if (!store) {
return {
activeTabId: null,
activeWorktreeId: null,
tabs: [],
ptyIdsByTabId: {},
ptyIdsByLeafIdByTabId: {}
}
}
const state = store.getState()
const tabs = state.tabsByWorktree[id] ?? []
return {
activeTabId: state.activeTabId,
activeWorktreeId: state.activeWorktreeId,
tabs: tabs.map((tab) => ({
id: tab.id,
ptyId: tab.ptyId,
generation: tab.generation,
pendingActivationSpawn: tab.pendingActivationSpawn
})),
ptyIdsByTabId: Object.fromEntries(
tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []])
),
ptyIdsByLeafIdByTabId: Object.fromEntries(
tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}])
)
}
}, worktreeId)
}
async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise<boolean> {
return page.evaluate(
async ({ targetPtyId, expectedText }) => {
const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, {
scrollbackRows: 200
})
return snapshot?.data.includes(expectedText) ?? false
},
{ targetPtyId: ptyId, expectedText: text }
)
}
function richSleepWakePayload(runId: string): string {
const shortId = runId.slice(0, 8)
return [
'\x1b[?2026h',
'\x1b[2J\x1b[H',
'╭────────────────────────────────────────────╮',
`│ sleep wake restore ${shortId} 😀 │`,
'├────────────┬───────────────┬───────────────┤',
'│ agent │ status │ output │',
'├────────────┼───────────────┼───────────────┤',
`│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`,
'│ opencode │ streaming │ unicode ✓ │',
'│ shell │ idle │ prompt ready │',
'╰────────────┴───────────────┴───────────────╯',
`SLEEP_WAKE_RESTORE_${runId}`,
`SLEEP_WAKE_TABLE_${runId}`,
'\x1b[?2026l'
].join('\r\n')
}
function sleepWakeExpectedMarkers(runId: string): string[] {
return [
`SLEEP_WAKE_RESTORE_${runId}`,
`SLEEP_WAKE_TABLE_${runId}`,
'box/table ok',
'unicode ✓',
'prompt ready'
]
}
function writeSleepWakePayloadScript(scriptPath: string, payload: string): void {
const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
writeFileSync(
scriptPath,
`process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`,
'utf8'
)
}
test.describe('Terminal sleep wake restore', () => {
test('restores slept terminal output and accepts fresh input after wake', async ({
orcaPage,
testRepoPath
}) => {
await waitForSessionReady(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}`
const freshMarker = `SLEEP_WAKE_FRESH_${runId}`
const expectedMarkers = sleepWakeExpectedMarkers(runId)
const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`)
writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000)
const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
for (const marker of expectedMarkers) {
expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true)
}
await switchToWorktree(orcaPage, firstWorktreeId)
await sleepWorktreeTerminals(orcaPage, secondWorktreeId)
const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
await expect
.poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), {
timeout: 10_000,
message: 'sleep did not release live PTYs for the background worktree'
})
.toBe(0)
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const awakePtyId = await waitForActivePanePtyId(orcaPage)
const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000)
for (const marker of expectedMarkers) {
expect
.soft(awakeTerminalContent.includes(marker), {
message: JSON.stringify(
{
missingMarker: marker,
ptyId,
awakePtyId,
beforeSleepDebug,
afterSleepDebug,
afterWakeDebug,
terminalTail: awakeTerminalContent.slice(-2000)
},
null,
2
)
})
.toBe(true)
}
await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000)
await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`)
await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000)
} finally {
rmSync(scriptPath, { force: true })
}
})
})