From 15420829ee65797262aca78683df29fdf48d5969 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:10:27 -0700 Subject: [PATCH] test(terminal): release-gate duplicate PTY renderer restore (#11947) --- config/reliability-gates.jsonc | 122 ++++++++ ...inal-duplicate-pty-renderer-reveal.spec.ts | 264 ++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 80cb0d586..ded4255d7 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1271,6 +1271,128 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.layout-pty-ownership", + "title": "Restored terminal layouts retain one renderer owner per PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-electron-restore", + "surfaces": [ + "persisted terminal layout restore", + "daemon PTY reattach", + "terminal tab hide and reveal", + "xterm renderer ownership", + "remote terminal layout mirroring" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "Provider-independent unit tests cover persisted replay, state-boundary authority transfer, remote-runtime mirroring, rootless layouts, repeated leaf ids, metadata repair, and 12,000 nested duplicate leaves. A two-launch macOS Electron journey covers a real surviving local-daemon PTY, persisted duplicate ownership, synchronized streaming output, and tab hide/reveal. Live Linux, Windows, SSH, WSL, and remote-runtime restore journeys remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11757", + "https://github.com/stablyai/orca/pull/11726" + ], + "invariant": "Within one terminal tab, each PTY has at most one layout leaf, pane manager surface, and xterm renderer owner. Restore and remote mirroring must normalize duplicate ownership before replay while retaining active-leaf focus, scrollback, pane authority, agent metadata, and distinct sibling PTYs.", + "oracle": "Launch Orca with a real daemon PTY running a synchronized full-screen stream, close the desktop client without killing the daemon process, seed the persisted tab layout with two leaves bound to that PTY, and relaunch. Hide the restored tab behind a sibling and reveal it, then require the stream to remain visible with exactly one manager pane, one xterm DOM node, one root leaf, one PTY binding, and one unique PTY. Unit contracts require the same one-owner result through persisted replay, store hydration, remote mirroring, rootless and repeated-leaf layouts, and deeply nested input.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", + "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts", + "assertions": [ + "replays one surface when restored leaves point to the same PTY", + "reattaches one PTY when the split repeats its bound leaf id" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts", + "assertions": ["prunes deeply nested duplicate ownership without recursive stack growth"] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts", + "assertions": [ + "keeps the active leaf and prunes the stale surface plus its metadata", + "is idempotent and preserves one owner across PTY and focus permutations" + ] + }, + { + "file": "src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts", + "assertions": [ + "normalizes duplicate PTY surfaces at the renderer state boundary", + "moves hydrated pane authority onto the retained PTY leaf" + ] + }, + { + "file": "src/renderer/src/runtime/web-session-tabs-sync.test.ts", + "assertions": ["deduplicates mirrored leaves that claim the same remote PTY"] + }, + { + "file": "tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts", + "assertions": ["repairs duplicate persisted PTY renderers before streaming tab reveal"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-layout-duplicate-pty-replay.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership-depth.test.ts src/renderer/src/components/terminal-pane/terminal-layout-pty-ownership.test.ts src/renderer/src/store/slices/terminal-layout-pty-ownership.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 1.33, + "summary": "Five focused files passed 92 ownership, replay, hydration, depth, and remote-mirroring tests." + }, + { + "date": "2026-08-01", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 8.7, + "summary": "The two-launch Electron journey preserved one real daemon PTY and one renderer through duplicate-layout repair and post-relaunch tab reveal." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused ownership unit suite plus one prebuilt two-launch Electron journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic unit and Electron gates pass locally on macOS; focused CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Orca 1.4.161 visibly strands and fragments the synchronized stream after restoring duplicate PTY renderers (https://github.com/user-attachments/assets/6917ac6f-9ba3-4f6f-a082-fb62932d511f). The post-resize capture remains corrupted, with duplicated lower rows and the terminal still stranded on the right; it is not recovery evidence (https://github.com/user-attachments/assets/32f7be79-0919-48c2-bcad-e4b7f1e14f2d). After the #11726 ownership repair, the equivalent stream remains continuous in one full-width renderer (https://github.com/user-attachments/assets/8c9cc5a2-dbe3-4348-b05c-f8268b98c476), and the final automated journey reports one owner at every layer after relaunch and tab reveal (https://github.com/user-attachments/assets/f19b46aa-e56f-476c-9e2c-e4c3a784c805)." + }, + "performanceBudget": { + "required": true, + "evidence": "Ownership normalization is bounded by layout size, uses iterative traversal, and passes a 12,000-leaf duplicate layout without recursive stack growth. The follow-up adds no production polling, renderer work, listeners, persistence scans, or retained payloads." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the live two-launch journey on Linux and Windows.", + "Add live SSH or remote-runtime duplicate-restore evidence before claiming provider-complete coverage." + ], + "knownGaps": [ + "Live Electron evidence currently covers macOS with a local daemon PTY only.", + "The live journey uses a git-backed test workspace; folder-workspace restoration currently relies on the same provider-independent normalization contracts rather than a separate Electron run.", + "The gate deterministically seeds the historical persisted-state shape; it does not depend on reproducing the unknown UI sequence that originally wrote duplicate ownership.", + "The visual old-release evidence and the structural current-version oracle are separate runs because the current test harness did not exist in Orca 1.4.161." + ], + "demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization." + }, { "id": "terminal-session.kill-all-surface-cleanup", "title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings", diff --git a/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts new file mode 100644 index 000000000..b11c74c86 --- /dev/null +++ b/tests/e2e/terminal-duplicate-pty-renderer-reveal.spec.ts @@ -0,0 +1,264 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { TerminalLayoutSnapshot } from '../../src/shared/types' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { test, expect } from './helpers/orca-app' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { stageNodeScriptForTerminal } from './helpers/run-node-script-in-terminal' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForSessionReady +} from './helpers/store' +import { TEST_REPO_PATH_FILE } from './global-setup' + +type PersistedData = { + workspaceSession?: { + activeTabId?: string | null + terminalLayoutsByTabId?: Record + } +} + +type RendererOwnershipSnapshot = { + paneCount: number + xtermCount: number + rootLeafCount: number + ptyBindingCount: number + uniquePtyCount: number +} + +function streamingTuiSource(marker: string): string { + return ` +let frame = 0 +process.stdout.write('\\x1b[?1049h\\x1b[?25l') +setInterval(() => { + frame += 1 + const lines = [${JSON.stringify(marker)} + ' frame ' + String(frame).padStart(6, '0')] + for (let row = 1; row <= 32; row += 1) { + const width = 8 + ((frame + row * 7) % 48) + lines.push(String(row).padStart(2, '0') + ' OpenCode tool output ' + '#'.repeat(width)) + } + process.stdout.write('\\x1b[?2026h\\x1b[H' + lines.join('\\r\\n') + '\\x1b[J\\x1b[?2026l') +}, 32) +`.trim() +} + +function persistedDataPath(userDataDir: string): string { + return path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') +} + +function seedDuplicatePtyOwnership(userDataDir: string): void { + const dataPath = persistedDataPath(userDataDir) + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as PersistedData + const session = data.workspaceSession + const tabId = session?.activeTabId + const layout = tabId ? session?.terminalLayoutsByTabId?.[tabId] : undefined + const retainedLeafId = layout?.activeLeafId + const ptyId = retainedLeafId ? layout?.ptyIdsByLeafId?.[retainedLeafId] : undefined + if (!session?.terminalLayoutsByTabId || !tabId || !layout || !retainedLeafId || !ptyId) { + throw new Error('Persisted terminal ownership was unavailable for duplicate-layout seeding') + } + + const duplicateLeafId = randomUUID() + session.terminalLayoutsByTabId[tabId] = { + ...layout, + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: retainedLeafId }, + second: { type: 'leaf', leafId: duplicateLeafId } + }, + activeLeafId: retainedLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { + [retainedLeafId]: ptyId, + [duplicateLeafId]: ptyId + } + } + writeFileSync(dataPath, `${JSON.stringify(data, null, 2)}\n`) +} + +async function waitForRestoredTerminal(page: Page, worktreeId: string): Promise { + await waitForSessionReady(page) + await expect.poll(() => getActiveWorktreeId(page), { timeout: 15_000 }).toBe(worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('Restored terminal tab was unavailable') + } + return tabId +} + +async function readRendererOwnership( + page: Page, + tabId: string +): Promise { + return page.evaluate((tabId) => { + const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] + const manager = window.__paneManagers?.get(tabId) + const surface = document.querySelector( + `[data-terminal-tab-id="${CSS.escape(tabId)}"][data-terminal-layout-leaf-ids]` + ) + const countLeaves = (node: TerminalLayoutSnapshot['root']): number => + !node ? 0 : node.type === 'leaf' ? 1 : countLeaves(node.first) + countLeaves(node.second) + const ptyIds = Object.values(layout?.ptyIdsByLeafId ?? {}) + return { + paneCount: manager?.getPanes?.().length ?? 0, + xtermCount: surface?.querySelectorAll('.xterm').length ?? 0, + rootLeafCount: countLeaves(layout?.root ?? null), + ptyBindingCount: ptyIds.length, + uniquePtyCount: new Set(ptyIds).size + } + }, tabId) +} + +async function readStreamingFrame( + page: Page, + tabId: string, + marker: string +): Promise { + const content = await page.evaluate((tabId) => { + const pane = window.__paneManagers?.get(tabId)?.getPanes?.()[0] + return pane?.serializeAddon?.serialize?.() ?? null + }, tabId) + return parseStreamingFrame(content, marker) +} + +async function readMainStreamingFrame( + page: Page, + ptyId: string, + marker: string +): Promise { + const content = await page.evaluate(async (ptyId) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, { scrollbackRows: 0 }) + return snapshot?.data ?? null + }, ptyId) + return parseStreamingFrame(content, marker) +} + +function parseStreamingFrame(content: string | null, marker: string): number | null { + const prefix = `${marker} frame ` + const start = content?.indexOf(prefix) ?? -1 + if (!content || start < 0) { + return null + } + const digits = content.slice(start + prefix.length).match(/^\d+/)?.[0] + return digits ? Number(digits) : null +} + +test('repairs duplicate persisted PTY renderers before streaming tab reveal', async (// oxlint-disable-next-line no-empty-pattern -- this restart test owns its Electron launches. +{}, testInfo) => { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Seeded E2E repository is unavailable') + + const session = createRestartSession(testInfo) + const marker = `DUPLICATE_PTY_REVEAL_${randomUUID()}` + const tui = stageNodeScriptForTerminal(streamingTuiSource(marker)) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + + try { + const firstLaunch = await session.launch() + firstApp = firstLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(firstLaunch.page, repoPath) + await waitForSessionReady(firstLaunch.page) + await ensureTerminalVisible(firstLaunch.page) + await waitForActiveTerminalManager(firstLaunch.page, 30_000) + const firstPtyId = await waitForActivePanePtyId(firstLaunch.page) + await execInTerminal(firstLaunch.page, firstPtyId, tui.command) + await waitForTerminalOutput(firstLaunch.page, marker, 20_000) + tui.cleanup() + + await session.close(firstApp) + firstApp = null + seedDuplicatePtyOwnership(session.userDataDir) + + const secondLaunch = await session.launch() + secondApp = secondLaunch.app + const restoredTabId = await waitForRestoredTerminal(secondLaunch.page, worktreeId) + await waitForTerminalOutput(secondLaunch.page, marker, 20_000) + const frameBeforeHide = await readMainStreamingFrame(secondLaunch.page, firstPtyId, marker) + if (frameBeforeHide === null) { + throw new Error('Authoritative TUI frame was unavailable before hiding the restored tab') + } + + const siblingTabId = await secondLaunch.page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + return store.getState().createTab(worktreeId, undefined, undefined, { activate: false }).id + }, worktreeId) + await secondLaunch.page.evaluate( + (tabId) => window.__store?.getState().setActiveTab(tabId), + siblingTabId + ) + await expect + .poll(() => getActiveTabId(secondLaunch.page), { timeout: 10_000 }) + .toBe(siblingTabId) + const restoredSurface = secondLaunch.page.locator( + `[data-terminal-tab-id=${JSON.stringify(restoredTabId)}]` + ) + await expect(restoredSurface).toBeHidden() + await expect + .poll(() => readMainStreamingFrame(secondLaunch.page, firstPtyId, marker), { + timeout: 10_000, + message: 'Authoritative TUI output did not advance while the restored tab was hidden' + }) + .toBeGreaterThan(frameBeforeHide) + const hiddenFrame = await readMainStreamingFrame(secondLaunch.page, firstPtyId, marker) + if (hiddenFrame === null || hiddenFrame <= frameBeforeHide) { + throw new Error('Authoritative TUI output did not remain advanced while the tab was hidden') + } + await secondLaunch.page.evaluate( + (tabId) => window.__store?.getState().setActiveTab(tabId), + restoredTabId + ) + await expect + .poll(() => getActiveTabId(secondLaunch.page), { timeout: 10_000 }) + .toBe(restoredTabId) + await expect + .poll(() => readStreamingFrame(secondLaunch.page, restoredTabId, marker), { + timeout: 20_000, + message: 'Revealed renderer did not catch up to hidden authoritative output' + }) + .toBeGreaterThanOrEqual(hiddenFrame) + + await expect + .poll(() => readRendererOwnership(secondLaunch.page, restoredTabId), { timeout: 10_000 }) + .toEqual({ + paneCount: 1, + xtermCount: 1, + rootLeafCount: 1, + ptyBindingCount: 1, + uniquePtyCount: 1 + }) + await testInfo.attach('duplicate-pty-renderer-after-reveal.png', { + body: await secondLaunch.page.screenshot(), + contentType: 'image/png' + }) + } finally { + tui.cleanup() + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})