From 73ff0031478380fdd59b3b7ff508fa1e5204d697 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:37:05 -0700 Subject: [PATCH] test(e2e): cover session upgrade and Windows terminal recovery (#17289) * coverage report * rm test coverage * test(e2e): cover session upgrade and Windows terminal recovery * fix stub --- config/reliability-gates.jsonc | 49 ++-- .../golden-stub-agent/golden-stub-agent.js | 12 +- ...acy-workspace-session-daemon-terminal.json | 48 ++++ tests/e2e/helpers/golden-stub-agent.ts | 7 +- ...rsisted-session-production-upgrade.spec.ts | 171 ++++++++++++++ ...inal-windows-conpty-keyboard-reset.spec.ts | 102 +++++++++ .../e2e/windows-wsl-terminal-restore.spec.ts | 212 ++++++++++++++++++ 7 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/fixtures/persisted-sessions/legacy-workspace-session-daemon-terminal.json create mode 100644 tests/e2e/persisted-session-production-upgrade.spec.ts create mode 100644 tests/e2e/terminal-windows-conpty-keyboard-reset.spec.ts create mode 100644 tests/e2e/windows-wsl-terminal-restore.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 4dea7b1e3e2..f06a92c309e 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8151,15 +8151,15 @@ "id": "startup-upgrade.persisted-session-corpus", "title": "Current Orca preserves or recovers old production persisted sessions", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "startup-persistence", "layer": "upgrade-fixture", "surfaces": ["startup", "upgrade", "session restore", "daemon restore"], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "wsl"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon"], + "coverageNotes": "A macOS Electron journey materializes the immutable legacy workspace-session fixture over a live update-surviving daemon PTY, boots current Orca without a current-format session write, and proves the terminal title, marker output, exact PTY, tab identity, repo, and worktree survive both the upgrade launch and a second current-version relaunch. Linux, Windows, SSH, and WSL fixture journeys remain uncollected.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/5356", "https://github.com/stablyai/orca/pull/5234", @@ -8167,21 +8167,40 @@ ], "invariant": "Startup and restore fixes must preserve or explicitly recover sessions from the last affected production persisted-state schema, not only from state written by current code.", "oracle": "Boot current Orca against immutable copied user-data fixtures from affected versions and reject blank replacement panes, duplicate resume tabs, silent session loss, or works-only-after-current-write behavior.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], + "commands": [ + "SKIP_BUILD=1 pnpm run test:e2e -- tests/e2e/persisted-session-production-upgrade.spec.ts" + ], + "testFiles": ["tests/e2e/persisted-session-production-upgrade.spec.ts"], + "assertionRefs": [ + { + "file": "tests/e2e/persisted-session-production-upgrade.spec.ts", + "assertions": [ + "upgrades a legacy daemon session and keeps it stable after relaunch" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-29", + "runner": "local", + "platform": "macos", + "command": "SKIP_BUILD=1 pnpm run test:e2e -- tests/e2e/persisted-session-production-upgrade.spec.ts", + "result": "passed", + "durationSeconds": 16.7, + "summary": "The legacy-shaped session retained its exact daemon PTY, terminal output marker, tab, repository, and worktree through the upgrade launch and a second current-version relaunch." + } + ], "runtimeBudget": { "p95Seconds": 120, "scope": "focused Electron upgrade fixture" }, "flakeHistory": { - "status": "not-started", - "evidence": "Fixture corpus not implemented." + "status": "unknown", + "evidence": "One focused local macOS Electron run passed; CI soak history is not collected." }, "redGreenEvidence": { - "status": "missing", - "evidence": "Needs immutable pre-fix persisted-state fixture." + "status": "partial", + "evidence": "The immutable legacy-shaped fixture and first-write/second-restart oracle are executable; saved intentional-break red/green evidence is still required." }, "performanceBudget": { "required": true, @@ -8192,7 +8211,11 @@ "Run second restart after current code writes upgraded state.", "Record startup timing and failure artifact." ], - "knownGaps": ["No fixture corpus or command yet."], + "knownGaps": [ + "The corpus currently contains one minimized legacy local-daemon workspace session.", + "Linux, Windows, SSH, and WSL upgrade fixtures remain uncollected.", + "The setup uses a current binary to create the live daemon PTY that stands in for the pre-upgrade process; it does not launch an installed legacy artifact." + ], "demotionRule": "Cannot promote without old production fixture provenance." }, { diff --git a/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js b/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js index 642229649af..5c353ce2bc6 100755 --- a/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js +++ b/tests/e2e/fixtures/golden-stub-agent/golden-stub-agent.js @@ -4,6 +4,7 @@ const READY_MARKER = 'GOLDEN_STUB_AGENT_READY' const EXIT_MARKER = 'GOLDEN_STUB_AGENT_EXITED' const ESC = '\x1b' +const keyboardProtocolMode = process.argv.includes('--keyboard-protocol') // Both match the bytes after ESC, so the control character stays out of the // pattern: a CSI/SS3 introducer still missing its final byte, and a complete // CSI/SS3 sequence. Shift+Enter is matched before either is consulted. @@ -19,7 +20,7 @@ function render() { const lines = composer.split('\n') const renderedComposer = lines.map((line, index) => `${index === 0 ? '> ' : ' '}${line}`) process.stdout.write( - `\x1b]0;Golden Stub Agent\x07${[ + `${keyboardProtocolMode ? '\x1b]0;\u280b Codex is thinking\x07\x1b[>1u' : '\x1b]0;Golden Stub Agent\x07'}${[ '\x1b[H\x1b[2JGolden Stub Agent', `[${READY_MARKER}]`, '', @@ -39,7 +40,8 @@ function exitCleanly() { if (process.stdin.isTTY) { process.stdin.setRawMode(false) } - process.stdout.write(`\x1b[?1049l[${EXIT_MARKER}]\r\n`, () => process.exit(0)) + const idleTitle = keyboardProtocolMode ? '\x1b]0;Codex\x07' : '' + process.stdout.write(`${idleTitle}\x1b[?1049l[${EXIT_MARKER}]\r\n`, () => process.exit(0)) } function submit() { @@ -54,6 +56,12 @@ function submit() { function consumeInput() { while (pendingInput.length > 0 && !exiting) { + const enter = ['\x1b[13u', '\x1b[13;1u'].find((sequence) => pendingInput.startsWith(sequence)) + if (enter) { + pendingInput = pendingInput.slice(enter.length) + submit() + continue + } const shiftEnter = ['\x1b[13;2u', '\x1b[13;2~', '\x1b\r'].find((sequence) => pendingInput.startsWith(sequence) ) diff --git a/tests/e2e/fixtures/persisted-sessions/legacy-workspace-session-daemon-terminal.json b/tests/e2e/fixtures/persisted-sessions/legacy-workspace-session-daemon-terminal.json new file mode 100644 index 00000000000..c01104e2b17 --- /dev/null +++ b/tests/e2e/fixtures/persisted-sessions/legacy-workspace-session-daemon-terminal.json @@ -0,0 +1,48 @@ +{ + "_fixtureProvenance": { + "description": "Legacy Orca workspace session with one daemon-backed terminal tab, used to verify upgrade and relaunch recovery.", + "sourceVersion": "legacy production workspaceSession shape", + "minimizedAt": "2026-07-01", + "notes": [ + "The pre-fix shape intentionally omits sleepingAgentSessionsByPaneKey.", + "Legacy tab and layout PTY ids are materialized from the update-surviving daemon session." + ] + }, + "activeRepoId": "__REPO_ID__", + "activeWorktreeId": "__WORKTREE_ID__", + "activeTabId": "production-agent-tab", + "tabsByWorktree": { + "__WORKTREE_ID__": [ + { + "id": "production-agent-tab", + "ptyId": "__PTY_ID__", + "worktreeId": "__WORKTREE_ID__", + "title": "Production agent session", + "defaultTitle": "Terminal 1", + "generatedTitle": "Production agent session", + "customTitle": "Production agent session", + "color": null, + "sortOrder": 0, + "createdAt": 1717444800000, + "launchAgent": "codex" + } + ] + }, + "terminalLayoutsByTabId": { + "production-agent-tab": { + "root": { "type": "leaf", "leafId": "pane:production-agent" }, + "activeLeafId": "pane:production-agent", + "expandedLeafId": null, + "ptyIdsByLeafId": { + "pane:production-agent": "__PTY_ID__" + }, + "titlesByLeafId": { + "pane:production-agent": "Production agent session" + } + } + }, + "activeWorktreeIdsOnShutdown": ["__WORKTREE_ID__"], + "activeTabIdByWorktree": { + "__WORKTREE_ID__": "production-agent-tab" + } +} diff --git a/tests/e2e/helpers/golden-stub-agent.ts b/tests/e2e/helpers/golden-stub-agent.ts index 5f01e691c53..427a6a2560c 100644 --- a/tests/e2e/helpers/golden-stub-agent.ts +++ b/tests/e2e/helpers/golden-stub-agent.ts @@ -26,13 +26,14 @@ export async function configureGoldenStubAgent( page: Page, options: { agent?: (typeof GOLDEN_STUB_AGENTS)[number]['id'] + agentArgs?: string /** Windows default shell the launch command must survive; ignored elsewhere. */ windowsShell?: BuiltInWindowsTerminalShell } = {} ): Promise { const agent = options.agent ?? 'codex' await page.evaluate( - async ({ agent, windowsShell }) => { + async ({ agent, agentArgs, windowsShell }) => { const store = window.__store if (!store) { throw new Error('Orca store is unavailable') @@ -40,11 +41,11 @@ export async function configureGoldenStubAgent( await store.getState().updateSettings({ defaultTuiAgent: agent, agentCmdOverrides: { [agent]: 'golden-stub-agent' }, - agentDefaultArgs: { [agent]: '' }, + agentDefaultArgs: { [agent]: agentArgs }, ...(windowsShell ? { terminalWindowsShell: windowsShell } : {}) }) }, - { agent, windowsShell: options.windowsShell ?? null } + { agent, agentArgs: options.agentArgs ?? '', windowsShell: options.windowsShell ?? null } ) } diff --git a/tests/e2e/persisted-session-production-upgrade.spec.ts b/tests/e2e/persisted-session-production-upgrade.spec.ts new file mode 100644 index 00000000000..3f63366b25f --- /dev/null +++ b/tests/e2e/persisted-session-production-upgrade.spec.ts @@ -0,0 +1,171 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForSessionReady } from './helpers/store' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const FIXTURE_PATH = path.join( + process.cwd(), + 'tests', + 'e2e', + 'fixtures', + 'persisted-sessions', + 'legacy-workspace-session-daemon-terminal.json' +) +// This fixture captures a legacy production schema boundary; the test runs the current build. +const RESTORED_TITLE = 'Production agent session' + +type FixtureSession = { + _fixtureProvenance?: unknown + activeRepoId: string + activeWorktreeId: string + tabsByWorktree: Record + terminalLayoutsByTabId: Record }> + activeWorktreeIdsOnShutdown?: string[] + activeTabIdByWorktree?: Record +} + +function installProductionSessionFixture( + userDataDir: string, + repoId: string, + worktreeId: string, + ptyId: string +): void { + const profilePath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + const profile = JSON.parse(readFileSync(profilePath, 'utf8')) as Record + const fixture = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as FixtureSession + const fixtureTabs = fixture.tabsByWorktree.__WORKTREE_ID__ + const fixtureLayout = fixture.terminalLayoutsByTabId['production-agent-tab'] + if (!fixtureTabs || !fixtureLayout) { + throw new Error('Production session fixture is missing its terminal records') + } + delete fixture._fixtureProvenance + fixture.activeRepoId = repoId + fixture.activeWorktreeId = worktreeId + fixture.tabsByWorktree = { + [worktreeId]: fixtureTabs.map((tab) => ({ + ...tab, + ptyId, + worktreeId + })) + } + fixtureLayout.ptyIdsByLeafId = { + 'pane:production-agent': ptyId + } + fixture.activeWorktreeIdsOnShutdown = [worktreeId] + fixture.activeTabIdByWorktree = { [worktreeId]: 'production-agent-tab' } + profile.workspaceSession = fixture + writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`) +} + +async function expectProductionSessionRestored( + page: Page, + expected: { marker: string; ptyId: string; repoId: string; worktreeId: string } +): Promise { + await waitForSessionReady(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + await waitForTerminalOutput(page, expected.marker, 30_000) + + await expect( + page.locator('[data-testid="sortable-tab"]').filter({ hasText: RESTORED_TITLE }) + ).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('[data-testid="sortable-tab"]')).toHaveCount(1) + await expect(page.locator('.xterm').first()).toBeVisible() + expect(await discoverActivePtyId(page)).toBe(expected.ptyId) + expect(await getTerminalContent(page)).toContain(expected.marker) + expect( + await page.evaluate(() => { + const state = window.__store?.getState() + return { + activeRepoId: state?.activeRepoId, + activeWorktreeId: state?.activeWorktreeId, + tabIds: state?.activeWorktreeId + ? state.tabsByWorktree[state.activeWorktreeId]?.map((tab) => tab.id) + : [] + } + }) + ).toEqual({ + activeRepoId: expected.repoId, + activeWorktreeId: expected.worktreeId, + tabIds: ['production-agent-tab'] + }) +} + +test('upgrades a legacy daemon session and keeps it stable after relaunch', async (// oxlint-disable-next-line no-empty-pattern -- this upgrade test owns its Electron launches. +{}, testInfo) => { + test.setTimeout(300_000) + 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) + let oldApp: ElectronApplication | null = null + let currentApp: ElectronApplication | null = null + let stableApp: ElectronApplication | null = null + + try { + const oldLaunch = await session.launch() + oldApp = oldLaunch.app + const worktreeId = await attachRepoAndOpenTerminal(oldLaunch.page, repoPath) + await waitForSessionReady(oldLaunch.page) + await ensureTerminalVisible(oldLaunch.page) + await waitForActiveTerminalManager(oldLaunch.page, 30_000) + await waitForPaneCount(oldLaunch.page, 1, 30_000) + + const ptyId = await discoverActivePtyId(oldLaunch.page) + expect(ptyId).toContain(PTY_SESSION_ID_SEPARATOR) + const marker = `PRODUCTION_UPGRADE_${Date.now()}` + await execInTerminal(oldLaunch.page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(oldLaunch.page, marker) + const repoId = await oldLaunch.page.evaluate( + (repoPath) => window.__store?.getState().repos.find((repo) => repo.path === repoPath)?.id, + repoPath + ) + if (!repoId) { + throw new Error('Active repository was unavailable before fixture installation') + } + + await session.close(oldApp) + oldApp = null + installProductionSessionFixture(session.userDataDir, repoId, worktreeId, ptyId) + + const currentLaunch = await session.launch() + currentApp = currentLaunch.app + const expected = { marker, ptyId, repoId, worktreeId } + await expectProductionSessionRestored(currentLaunch.page, expected) + + await session.close(currentApp) + currentApp = null + + const stableLaunch = await session.launch() + stableApp = stableLaunch.app + await expectProductionSessionRestored(stableLaunch.page, expected) + } finally { + for (const app of [stableApp, currentApp, oldApp]) { + if (app) { + await session.close(app).catch(() => {}) + } + } + await session.dispose() + } +}) diff --git a/tests/e2e/terminal-windows-conpty-keyboard-reset.spec.ts b/tests/e2e/terminal-windows-conpty-keyboard-reset.spec.ts new file mode 100644 index 00000000000..5766c1e2d49 --- /dev/null +++ b/tests/e2e/terminal-windows-conpty-keyboard-reset.spec.ts @@ -0,0 +1,102 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + configureGoldenStubAgent, + getGoldenStubAgentLaunchEnv, + GOLDEN_STUB_EXIT_MARKER, + launchGoldenStubAgentFromNewTab +} from './helpers/golden-stub-agent' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForTerminalOutput +} from './helpers/terminal' +import { + clearTerminalPtyWriteLog, + installTerminalPtyWriteSpy, + readTerminalPtyWriteEntries +} from './helpers/terminal-pty-write-spy' + +test.use({ launchEnv: getGoldenStubAgentLaunchEnv() }) +test.skip(process.platform !== 'win32', 'A real Windows ConPTY is required') + +async function getKittyKeyboardFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const pane = tabId ? window.__paneManagers?.get(tabId)?.getActivePane?.() : null + const terminal = pane?.terminal as + | { + core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return ( + terminal?.core?.coreService?.kittyKeyboard?.flags ?? + terminal?._core?.coreService?.kittyKeyboard?.flags ?? + null + ) + }) +} + +test('resets standard keyboard bytes after a protocol-mode agent exits on ConPTY', async ({ + electronApp, + orcaPage +}) => { + await installTerminalPtyWriteSpy(electronApp) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await configureGoldenStubAgent(orcaPage, { agentArgs: '--keyboard-protocol' }) + await launchGoldenStubAgentFromNewTab(orcaPage) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await expect.poll(() => getKittyKeyboardFlags(orcaPage), { timeout: 10_000 }).toBe(1) + + await clearTerminalPtyWriteLog(electronApp) + await orcaPage.keyboard.type('exit') + await orcaPage.keyboard.press('Enter') + await waitForTerminalOutput(orcaPage, GOLDEN_STUB_EXIT_MARKER, 15_000) + const protocolWrites = (await readTerminalPtyWriteEntries(electronApp)) + .filter((entry) => entry.id === ptyId) + .map((entry) => entry.data) + .join('') + expect(protocolWrites.includes('\x1b[13u') || protocolWrites.includes('\x1b[13;1u')).toBe(true) + await expect.poll(() => getKittyKeyboardFlags(orcaPage), { timeout: 10_000 }).toBe(0) + + await clearTerminalPtyWriteLog(electronApp) + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type("Write-Output ('CONPTY_KEYBOARD_' + '") + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), 'REET_') + await orcaPage.keyboard.press('Control+V') + await orcaPage.keyboard.press('ArrowLeft') + await orcaPage.keyboard.press('ArrowLeft') + await orcaPage.keyboard.press('ArrowLeft') + await orcaPage.keyboard.type('S') + await orcaPage.keyboard.press('ArrowRight') + await orcaPage.keyboard.press('ArrowRight') + await orcaPage.keyboard.press('ArrowRight') + await orcaPage.keyboard.type('EXECUTEX') + await orcaPage.keyboard.press('Backspace') + await orcaPage.keyboard.type("D')") + await orcaPage.keyboard.press('Enter') + await waitForTerminalOutput(orcaPage, 'CONPTY_KEYBOARD_RESET_EXECUTED', 15_000) + + const shellWrites = (await readTerminalPtyWriteEntries(electronApp)) + .filter((entry) => entry.id === ptyId) + .map((entry) => entry.data) + const joinedShellWrites = shellWrites.join('') + expect(joinedShellWrites).toContain('REET_') + expect(shellWrites.filter((data) => data === '\x1b[D')).toHaveLength(3) + expect(shellWrites.filter((data) => data === '\x1b[C')).toHaveLength(3) + expect(shellWrites).toContain('\x7f') + expect(shellWrites).toContain('\r') + expect(joinedShellWrites).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\[\\d+(?:;\\d+)*u`)) +}) diff --git a/tests/e2e/windows-wsl-terminal-restore.spec.ts b/tests/e2e/windows-wsl-terminal-restore.spec.ts new file mode 100644 index 00000000000..47ea91ee441 --- /dev/null +++ b/tests/e2e/windows-wsl-terminal-restore.spec.ts @@ -0,0 +1,212 @@ +import { randomUUID } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { buildWslExecArgs } from '../../src/shared/wsl-login-shell-command' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { useWslRuntimeForActiveProject as selectWslRuntimeForActiveProject } from './helpers/wsl-golden-stub-agent' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + getTerminalContent, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +const REQUIRE_WSL_RESTORE = process.env.ORCA_REQUIRE_WSL_RESTORE_E2E === '1' + +type WslRestoreSnapshot = { + hostCwd: string + ptyId: string + sessionIds: string[] + shellOverride: string | undefined + tabIds: string[] +} + +function getGuestPath(distro: string, hostPath: string): string { + return execFileSync('wsl.exe', buildWslExecArgs(distro, ['wslpath', '-a', hostPath]), { + encoding: 'utf8', + stdio: 'pipe', + timeout: 15_000, + windowsHide: true + }).trim() +} + +async function createWslStartupTab( + page: Page, + worktreeId: string, + distro: string, + marker: string +): Promise { + await selectWslRuntimeForActiveProject(page, distro) + return page.evaluate( + ({ worktreeId, marker }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const tab = store.getState().createTab(worktreeId, undefined, undefined, { + pendingStartup: { + command: `printf '${marker}:%s\\n' "$PWD"`, + delivery: 'terminal-paste' + } + }) + store.getState().setActiveTab(tab.id) + store.getState().setActiveTabType('terminal') + return tab.id + }, + { worktreeId, marker } + ) +} + +async function readSnapshot( + page: Page, + worktreeId: string, + tabId: string +): Promise { + return page.evaluate( + async ({ worktreeId, tabId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Store unavailable') + } + const tab = (state.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === tabId + ) + const ptyIds = state.ptyIdsByTabId[tabId] ?? [] + if (!tab || ptyIds.length !== 1) { + throw new Error(`Expected one PTY for restored WSL tab ${tabId}`) + } + const sessions = await window.api.pty.listSessions() + const ownedSession = sessions.find((session) => session.id === ptyIds[0]) + if (!ownedSession) { + throw new Error(`WSL PTY ${ptyIds[0]} was absent from provider inventory`) + } + return { + hostCwd: ownedSession.cwd, + ptyId: ptyIds[0]!, + sessionIds: sessions.map((session) => session.id).sort(), + shellOverride: tab.shellOverride, + tabIds: (state.tabsByWorktree[worktreeId] ?? []).map((candidate) => candidate.id).sort() + } + }, + { worktreeId, tabId } + ) +} + +async function waitForRestoredWslTab(page: Page, worktreeId: string, tabId: string): Promise { + await waitForSessionReady(page, 60_000) + await expect.poll(() => waitForActiveWorktree(page), { timeout: 60_000 }).toBe(worktreeId) + await ensureTerminalVisible(page) + await page.evaluate((tabId) => { + const state = window.__store?.getState() + state?.setActiveTab(tabId) + state?.setActiveTabType('terminal') + }, tabId) + await waitForActiveTerminalManager(page, 60_000) + await waitForActivePanePtyId(page, 60_000) +} + +test('WSL terminal preserves guest cwd, liveness, and PTY ownership across relaunch', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns both Electron launches. +{}, testInfo) => { + test.skip(process.platform !== 'win32', 'WSL restore coverage requires a Windows host') + test.setTimeout(300_000) + + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim() + : '' + if (!repoPath || !existsSync(repoPath)) { + if (REQUIRE_WSL_RESTORE) { + throw new Error('Required WSL restore E2E seeded repo is unavailable') + } + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + + const restart = createRestartSession(testInfo) + let app: ElectronApplication | null = null + try { + const first = await restart.launch() + app = first.app + const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + + const distro = await first.page.evaluate(async () => { + if (!(await window.api.wsl.isAvailable())) { + return null + } + return (await window.api.wsl.listDistros())[0] ?? null + }) + if (!distro) { + if (REQUIRE_WSL_RESTORE) { + throw new Error('Required Windows WSL distro is unavailable') + } + test.skip(true, 'No WSL distro is available on this Windows host') + return + } + + const guestPath = getGuestPath(distro, repoPath) + const startupMarker = `WSL_RESTORE_STARTUP_${randomUUID().replaceAll('-', '_')}` + const tabId = await createWslStartupTab(first.page, worktreeId, distro, startupMarker) + await waitForActiveTerminalManager(first.page, 60_000) + const firstPtyId = await waitForActivePanePtyId(first.page, 60_000) + await waitForTerminalOutput(first.page, `${startupMarker}:${guestPath}`, 30_000) + + const baseline = await readSnapshot(first.page, worktreeId, tabId) + expect(baseline.hostCwd).toBe(repoPath) + expect(baseline.ptyId).toBe(firstPtyId) + expect(baseline.shellOverride).toBe('wsl.exe') + await expect( + first.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), firstPtyId) + ).resolves.toBe(true) + + await first.page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll( + () => + first.page.evaluate( + async ({ worktreeId, tabId, ptyId }) => { + const persisted = await window.api.session.get() + const tab = (persisted.tabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === tabId + ) + const layoutPtyId = persisted.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId + ? Object.values(persisted.terminalLayoutsByTabId[tabId]!.ptyIdsByLeafId!)[0] + : undefined + return tab?.id === tabId && layoutPtyId === ptyId + }, + { worktreeId, tabId, ptyId: firstPtyId } + ), + { timeout: 15_000, message: 'WSL tab ownership was not persisted before quit' } + ) + .toBe(true) + + await restart.close(app) + app = null + const second = await restart.launch() + app = second.app + await waitForRestoredWslTab(second.page, worktreeId, tabId) + await waitForTerminalOutput(second.page, `${startupMarker}:${guestPath}`, 30_000) + + const restored = await readSnapshot(second.page, worktreeId, tabId) + expect(restored).toEqual(baseline) + await expect( + second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), restored.ptyId) + ).resolves.toBe(true) + + const inputMarker = `WSL_RESTORE_INPUT_${randomUUID().replaceAll('-', '_')}` + await execInTerminal(second.page, restored.ptyId, `printf '${inputMarker}:%s\\n' "$PWD"`) + await waitForTerminalOutput(second.page, `${inputMarker}:${guestPath}`, 30_000) + expect(await getTerminalContent(second.page)).toContain(`${inputMarker}:${guestPath}`) + } finally { + if (app) { + await restart.close(app) + } + await restart.dispose() + } +})