mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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
This commit is contained in:
@@ -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."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, { ptyId: string; worktreeId: string }[]>
|
||||
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string> }>
|
||||
activeWorktreeIdsOnShutdown?: string[]
|
||||
activeTabIdByWorktree?: Record<string, string>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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<number | null> {
|
||||
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`))
|
||||
})
|
||||
@@ -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<string> {
|
||||
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<WslRestoreSnapshot> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user