mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
co-authored by
Jinwoo-H
Orca
parent
ac8d365296
commit
ff5833dd4f
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user