test(e2e): harden terminal restart regressions on Windows (#9064)

* test(e2e): harden Windows terminal restart regressions

* test(e2e): cover renderer replacement rejection

* test(e2e): tolerate ESRCH when force-killing the daemon on POSIX

* fix(ci): preserve Windows restart test selection

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
slashdevcorpse 2026-07-17 20:18:36 -04:00 committed by GitHub
parent 546cc8237f
commit 803a442868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 543 additions and 45 deletions

View File

@ -0,0 +1,103 @@
name: Windows terminal restart E2E
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/windows-terminal-restart-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'electron.vite.config.ts'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'tests/playwright.config.ts'
- 'tests/e2e/global-setup.ts'
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
- 'tests/e2e/terminal-restart-persistence.spec.ts'
- 'src/main/daemon/**'
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
ref:
description: Ref to check out
required: false
type: string
permissions:
contents: read
concurrency:
group: windows-terminal-restart-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
windows-terminal-restart:
name: Windows terminal restart regressions
runs-on: windows-2022
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
# Why: pnpm must exist before setup-node resolves its dependency cache.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for Windows E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run Windows terminal restart regressions
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E: '1'
# Why: pnpm forwards a literal `--` to Playwright, which makes the
# grep and worker flags positional filters instead of CLI options.
run: >-
pnpm run test:e2e
tests/e2e/restart-restore-terminal-input.spec.ts
tests/e2e/terminal-restart-persistence.spec.ts
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves"
--workers=1
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: windows-terminal-restart-playwright-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore

View File

@ -0,0 +1,80 @@
import { randomUUID } from 'node:crypto'
import type { Page } from '@stablyai/playwright-test'
import { buildFreshShellProbeInputSequence } from '../terminal-probe-input-sequence'
type ReadinessAttempt = {
marker: string
paneInstanceId: string
}
export async function waitForRestoredTerminalInputReady(
page: Page,
expectedPtyId: string,
timeoutMs = 15_000
): Promise<boolean> {
const deadline = Date.now() + timeoutMs
// Why: ConPTY echo can lag behind the poll interval, so retain every marker
// sent to the current concrete pane instead of chasing only the newest one.
let pendingAttempts: readonly ReadinessAttempt[] = []
while (Date.now() < deadline) {
const marker = `ORCA_RESTORED_INPUT_READY_${randomUUID().replaceAll('-', '')}`
const [input] = buildFreshShellProbeInputSequence(`echo ${marker}\r`)
if (!input) {
return false
}
try {
const result = await page.evaluate(
({ expectedPtyId, input, pendingAttempts }) => {
for (const manager of window.__paneManagers?.values() ?? []) {
const pane = manager.getActivePane?.() ?? manager.getPanes?.()[0]
if (pane?.container?.dataset?.ptyId !== expectedPtyId) {
continue
}
const container = pane.container as HTMLElement & {
__orcaE2eTerminalInputReadinessInstanceId?: string
}
container.__orcaE2eTerminalInputReadinessInstanceId ??= crypto.randomUUID()
const paneInstanceId = container.__orcaE2eTerminalInputReadinessInstanceId
const output = pane.serializeAddon?.serialize?.() ?? ''
if (
pendingAttempts.some(
(attempt) =>
attempt.paneInstanceId === paneInstanceId && output.includes(attempt.marker)
)
) {
return { ready: true, paneInstanceId }
}
// Why: one input() payload is either wholly replay-suppressed or
// wholly forwarded, unlike character-by-character keyboard typing.
pane.terminal.input(input, true)
return { ready: false, paneInstanceId }
}
return null
},
{ expectedPtyId, input, pendingAttempts }
)
if (result?.ready) {
return true
}
pendingAttempts = result
? [
...pendingAttempts.filter(
(attempt) => attempt.paneInstanceId === result.paneInstanceId
),
{ marker, paneInstanceId: result.paneInstanceId }
]
: []
} catch {
// Relaunch can replace the document between polls; the next attempt
// resolves the current pane and binding instead of retaining stale state.
pendingAttempts = []
}
const remainingMs = deadline - Date.now()
if (remainingMs > 0) {
await page.waitForTimeout(Math.min(100, remainingMs))
}
}
return false
}

View File

@ -11,7 +11,7 @@
*
* Direct `window.api.pty.write` bypasses the renderer transport, so:
* direct dead MAIN-side drop (ownership/provider routing)
* direct alive, transport dead RENDERER transport unbound
* direct alive, renderer dead RENDERER input path (replay, focus, binding)
* The ownership-rebuild probe invokes pty:listSessions, which repopulates
* `ptyOwnership` as a side effect input reviving after it is a smoking gun
* for the missing-ownership drop path.
@ -25,6 +25,7 @@
import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test'
import { sendToTerminal, waitForTerminalOutput } from './terminal'
import { buildSettledShellProbeInputSequence } from '../terminal-probe-input-sequence'
// ─── Page-based probes (healthy CDP session) ────────────────────────
@ -34,9 +35,9 @@ export async function probeDirectWrite(
marker: string,
timeoutMs = 10_000
): Promise<boolean> {
// \x03\x15 = ETX+NAK (interrupt + kill-line) so a TUI or half-typed line on
// the shell doesn't swallow the probe — same trick discoverActivePtyId uses.
await sendToTerminal(page, ptyId, `\x03\x15echo ${marker}\r`)
for (const input of buildSettledShellProbeInputSequence(`echo ${marker}\r`)) {
await sendToTerminal(page, ptyId, input)
}
try {
await waitForTerminalOutput(page, marker, timeoutMs)
return true
@ -209,6 +210,7 @@ export async function mainProbeTransportPaste(
timeoutMs = 10_000
): Promise<boolean> {
try {
const inputs = buildSettledShellProbeInputSequence(`echo ${marker}\r`)
const fed = await mainRendererEval<boolean>(
electronApp,
`(() => {
@ -217,7 +219,9 @@ export async function mainProbeTransportPaste(
for (const manager of managers.values()) {
const pane = manager.getActivePane?.() ?? (manager.getPanes?.() ?? [])[0]
if (pane?.terminal?.input) {
pane.terminal.input('\\x03\\x15echo ${marker}\\r', true)
for (const input of ${JSON.stringify(inputs)}) {
pane.terminal.input(input, true)
}
return true
}
}
@ -240,9 +244,10 @@ export async function mainProbeDirectWrite(
timeoutMs = 10_000
): Promise<boolean> {
try {
const inputs = buildSettledShellProbeInputSequence(`echo ${marker}\r`)
await mainRendererEval<void>(
electronApp,
`window.api.pty.write(${JSON.stringify(ptyId)}, ${JSON.stringify(`\x03\x15echo ${marker}\r`)})`
`for (const input of ${JSON.stringify(inputs)}) { window.api.pty.write(${JSON.stringify(ptyId)}, input) }`
)
} catch {
return false
@ -275,14 +280,20 @@ export function buildFrozenPaneReport(
directAlive: boolean
transportAlive: boolean
revivedByOwnershipRebuild: boolean
ownershipRebuildAttempted?: boolean
readinessAlive?: boolean
ptyIds: string[]
terminalTail: string
}
): string {
return [
`REPRODUCED frozen terminal (${context}):`,
...(probes.readinessAlive === undefined
? []
: [` replay + transport readiness probe alive: ${probes.readinessAlive}`]),
` direct pty:write probe alive: ${probes.directAlive} (false ⇒ MAIN-side drop: ptyOwnership/provider)`,
` transport (onData→sendInput) probe alive: ${probes.transportAlive} (false with direct alive ⇒ RENDERER transport unbound)`,
` renderer input-path probe alive: ${probes.transportAlive} (false with direct alive ⇒ replay, focus, or renderer binding failure)`,
` ownership rebuild attempted: ${probes.ownershipRebuildAttempted ?? true}`,
` revived by pty:listSessions ownership rebuild: ${probes.revivedByOwnershipRebuild}`,
` pane ptyIds: ${JSON.stringify(probes.ptyIds)}`,
` terminal tail:\n${probes.terminalTail.slice(-600)}`

View File

@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Terminal E2E helpers share one PaneManager-backed path for PTY IO, split actions, and stable pane identity snapshots. */
import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
import { buildFreshShellProbeInputSequence } from '../terminal-probe-input-sequence'
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
@ -264,15 +265,21 @@ export async function discoverActivePtyId(page: Page): Promise<string> {
throw new Error('discoverActivePtyId: active tab has no PTY candidates in store')
}
const candidateInputs = candidateIds.map((_id, index) =>
buildFreshShellProbeInputSequence(`echo ${marker}_${index}\r`)
)
await page.evaluate(
({ marker, candidateIds }) => {
({ candidateIds, candidateInputs }) => {
// Why: daemon PTY IDs can contain path separators and shell metacharacters.
// Echo a numeric probe index, then map it back to the opaque ID in Node.
for (const [index, id] of candidateIds.entries()) {
window.api.pty.write(String(id), `\x03\x15echo ${marker}_${index}\r`)
for (const input of candidateInputs[index] ?? []) {
window.api.pty.write(String(id), input)
}
}
},
{ marker, candidateIds }
{ candidateIds, candidateInputs }
)
let foundPtyId: string | null = null

View File

@ -20,6 +20,7 @@
* a typeable pane
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
@ -42,9 +43,13 @@ import {
probeKeyboardType,
probeOwnershipRebuildRevival
} from './helpers/terminal-input-probes'
import { waitForRestoredTerminalInputReady } from './helpers/restored-terminal-input-readiness'
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format'
const REQUIRE_WINDOWS_TERMINAL_RESTART_E2E =
process.env.ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E === '1'
function readDaemonPid(userDataDir: string): number {
const raw = readFileSync(
path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`),
@ -57,11 +62,61 @@ function readDaemonPid(userDataDir: string): number {
return parsed.pid
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
return false
}
throw error
}
}
async function terminateDaemonForColdRestart(pid: number): Promise<void> {
if (!isProcessAlive(pid)) {
return
}
if (process.platform === 'win32') {
try {
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'pipe',
timeout: 10_000
})
} catch (error) {
if (isProcessAlive(pid)) {
throw error
}
}
} else {
try {
process.kill(pid, 'SIGKILL')
} catch (error) {
// Why: the daemon can self-exit between the liveness guard and this
// signal; tolerate ESRCH like the Windows branch re-checks liveness.
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') {
throw error
}
}
}
await expect
.poll(() => isProcessAlive(pid), {
timeout: 10_000,
message: `daemon ${pid} remained alive after forced termination`
})
.toBe(false)
}
function seededRepoPathOrSkip(): string {
const repoPath = existsSync(TEST_REPO_PATH_FILE)
? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
: ''
test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo')
const unavailable = !repoPath || !existsSync(repoPath)
if (unavailable && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) {
throw new Error('Required Windows restart E2E seeded repo is unavailable')
}
test.skip(unavailable, 'Global setup did not produce a seeded test repo')
return repoPath
}
@ -99,18 +154,23 @@ async function settleRestoredLaunch(page: Page): Promise<void> {
*/
async function expectRestoredPaneAcceptsInput(page: Page, context: string): Promise<void> {
const ptyIds = await getStorePtyIds(page)
const kbAlive = await probeKeyboardType(page, 'KB_RESTORED_OK', 15_000)
const readinessAlive =
ptyIds.length > 0 && (await waitForRestoredTerminalInputReady(page, ptyIds[0], 15_000))
const kbAlive = readinessAlive && (await probeKeyboardType(page, 'KB_RESTORED_OK', 15_000))
const directAlive =
ptyIds.length > 0 && (await probeDirectWrite(page, ptyIds[0], 'DIRECT_RESTORED_OK', 15_000))
if (!kbAlive || !directAlive) {
if (!readinessAlive || !kbAlive || !directAlive) {
const ownershipRebuildAttempted = !directAlive && ptyIds.length > 0
const revived =
ptyIds.length > 0 &&
ownershipRebuildAttempted &&
(await probeOwnershipRebuildRevival(page, ptyIds[0], 'REVIVED_RESTORED_OK'))
throw new Error(
buildFrozenPaneReport(context, {
directAlive,
transportAlive: kbAlive,
revivedByOwnershipRebuild: revived,
ownershipRebuildAttempted,
readinessAlive,
ptyIds,
terminalTail: await getTerminalContent(page)
})
@ -223,7 +283,6 @@ test('restored pane recovers input after the daemon un-wedges', async (// oxlint
test('cold-restored pane accepts typing after the daemon died between launches', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
test.skip(process.platform === 'win32', 'POSIX signal semantics keep this deterministic')
test.setTimeout(300_000)
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
@ -240,7 +299,7 @@ test('cold-restored pane accepts typing after the daemon died between launches',
// The daemon dies uncleanly between runs (crash, reboot, force-kill). The
// persisted session now references sessions no living daemon holds.
process.kill(daemonPid, 'SIGKILL')
await terminateDaemonForColdRestart(daemonPid)
const second = await session.launch()
secondApp = second.app

View File

@ -0,0 +1,210 @@
import type { Page } from '@stablyai/playwright-test'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { waitForRestoredTerminalInputReady } from './helpers/restored-terminal-input-readiness'
type TestPane = {
container: {
dataset: { ptyId: string }
__orcaE2eTerminalInputReadinessInstanceId?: string
}
serializeAddon: { serialize: () => string }
terminal: { input: (data: string, wasUserInput: boolean) => void }
}
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window')
function installPaneWindow(pane: TestPane): void {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__paneManagers: new Map([
[
'tab-1',
{
getActivePane: () => pane,
getPanes: () => [pane]
}
]
])
}
})
}
function createPage(): Page {
return {
evaluate: async (callback: (arg: unknown) => unknown, arg: unknown) => callback(arg),
waitForTimeout: async (timeoutMs: number) => {
await vi.advanceTimersByTimeAsync(timeoutMs)
}
} as unknown as Page
}
describe('restored terminal input readiness', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
if (originalWindowDescriptor) {
Object.defineProperty(globalThis, 'window', originalWindowDescriptor)
} else {
Reflect.deleteProperty(globalThis, 'window')
}
})
it('retries after replay drops the first full input payload', async () => {
let content = ''
let attempts = 0
const input = vi.fn((data: string) => {
attempts += 1
if (attempts >= 2) {
content = data
}
})
installPaneWindow({
container: { dataset: { ptyId: 'pty-1' } },
serializeAddon: { serialize: () => content },
terminal: { input }
})
await expect(waitForRestoredTerminalInputReady(createPage(), 'pty-1', 500)).resolves.toBe(true)
expect(input).toHaveBeenCalledTimes(2)
expect(input.mock.calls.every(([, wasUserInput]) => wasUserInput === true)).toBe(true)
})
it('accepts a healthy PTY echo that arrives after multiple poll intervals', async () => {
let content = ''
const input = vi.fn((data: string) => {
setTimeout(() => {
content = data
}, 250)
})
installPaneWindow({
container: { dataset: { ptyId: 'pty-1' } },
serializeAddon: { serialize: () => content },
terminal: { input }
})
await expect(waitForRestoredTerminalInputReady(createPage(), 'pty-1', 800)).resolves.toBe(true)
expect(input.mock.calls.length).toBeGreaterThan(1)
})
it('never treats a different pane PTY as ready', async () => {
const input = vi.fn()
installPaneWindow({
container: { dataset: { ptyId: 'pty-other' } },
serializeAddon: { serialize: () => 'unrelated terminal output' },
terminal: { input }
})
await expect(waitForRestoredTerminalInputReady(createPage(), 'pty-1', 250)).resolves.toBe(false)
expect(input).not.toHaveBeenCalled()
})
it('does not accept a marker replayed into a replacement pane', async () => {
let activePane: TestPane
let replacementContent = ''
const replacementInput = vi.fn((data: string) => {
replacementContent = data
})
const replacementPane: TestPane = {
container: { dataset: { ptyId: 'pty-1' } },
serializeAddon: { serialize: () => replacementContent },
terminal: { input: replacementInput }
}
const firstInput = vi.fn((data: string) => {
replacementContent = data
activePane = replacementPane
})
activePane = {
container: { dataset: { ptyId: 'pty-1' } },
serializeAddon: { serialize: () => '' },
terminal: { input: firstInput }
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__paneManagers: new Map([
[
'tab-1',
{
getActivePane: () => activePane,
getPanes: () => [activePane]
}
]
])
}
})
await expect(waitForRestoredTerminalInputReady(createPage(), 'pty-1', 500)).resolves.toBe(true)
expect(firstInput).toHaveBeenCalledTimes(1)
expect(replacementInput).toHaveBeenCalledTimes(1)
})
it('discards stale attempts when document replacement rejects evaluation', async () => {
let activePane: TestPane
let replacementContent = ''
const replacementInput = vi.fn((data: string) => {
replacementContent = data
})
const replacementPane: TestPane = {
container: { dataset: { ptyId: 'pty-1' } },
serializeAddon: { serialize: () => replacementContent },
terminal: { input: replacementInput }
}
let originalInputCalls = 0
const firstInput = vi.fn((data: string) => {
originalInputCalls += 1
if (originalInputCalls === 2) {
replacementContent = data
activePane = replacementPane
}
})
activePane = {
container: { dataset: { ptyId: 'pty-1' } },
serializeAddon: { serialize: () => '' },
terminal: { input: firstInput }
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
__paneManagers: new Map([
[
'tab-1',
{
getActivePane: () => activePane,
getPanes: () => [activePane]
}
]
])
}
})
const pendingAttemptCounts: number[] = []
let evaluateCalls = 0
const page = {
evaluate: async (callback: (arg: unknown) => unknown, arg: unknown) => {
evaluateCalls += 1
pendingAttemptCounts.push(
(arg as { pendingAttempts: readonly unknown[] }).pendingAttempts.length
)
const result = callback(arg)
if (evaluateCalls === 2) {
throw new Error('Execution context was destroyed')
}
return result
},
waitForTimeout: async (timeoutMs: number) => {
await vi.advanceTimersByTimeAsync(timeoutMs)
}
} as unknown as Page
await expect(waitForRestoredTerminalInputReady(page, 'pty-1', 500)).resolves.toBe(true)
expect(pendingAttemptCounts).toEqual([0, 1, 0, 1])
expect(firstInput).toHaveBeenCalledTimes(2)
expect(replacementInput).toHaveBeenCalledTimes(1)
expect(replacementInput.mock.calls[0]?.[0]).not.toBe(firstInput.mock.calls[1]?.[0])
})
})

View File

@ -3,3 +3,12 @@ export function buildFreshShellProbeInputSequence(command: string): readonly str
// corrupts the following PowerShell command before the shell is ready.
return [command]
}
export function buildSettledShellProbeInputSequence(
command: string,
platform: NodeJS.Platform = process.platform
): readonly string[] {
// Why: Ctrl+U is a POSIX line-editor binding. A settled native Windows shell
// needs a separate Ctrl+C so ConPTY cannot join it to the command as input.
return platform === 'win32' ? ['\x03', command] : ['\x03\x15', command]
}

View File

@ -1,12 +1,26 @@
import { describe, expect, it } from 'vitest'
import { buildFreshShellProbeInputSequence } from './terminal-probe-input-sequence'
import {
buildFreshShellProbeInputSequence,
buildSettledShellProbeInputSequence
} from './terminal-probe-input-sequence'
const command = "& 'C:\\node\\node.exe' '-e' 'console.log(1)'\r"
describe('buildFreshShellProbeInputSequence', () => {
it('does not prefix fresh shell probes with interrupt or line-kill bytes', () => {
const command = "& 'C:\\node\\node.exe' '-e' 'console.log(1)'\r"
expect(buildFreshShellProbeInputSequence(command)).toEqual([command])
expect(buildFreshShellProbeInputSequence(command).join('')).not.toContain('\x03')
expect(buildFreshShellProbeInputSequence(command).join('')).not.toContain('\x15')
})
})
describe('buildSettledShellProbeInputSequence', () => {
it('resets PowerShell without sending the POSIX Ctrl+U binding', () => {
expect(buildSettledShellProbeInputSequence(command, 'win32')).toEqual(['\x03', command])
expect(buildSettledShellProbeInputSequence(command, 'win32').join('')).not.toContain('\x15')
})
it.each(['linux', 'darwin'] as const)('preserves the POSIX reset on %s', (platform) => {
expect(buildSettledShellProbeInputSequence(command, platform)).toEqual(['\x03\x15', command])
})
})

View File

@ -48,12 +48,28 @@ import {
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format'
const REQUIRE_WINDOWS_TERMINAL_RESTART_E2E =
process.env.ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E === '1'
const MISSING_SEEDED_REPO_MESSAGE = 'Global setup did not produce a seeded test repo'
// Why: each test in this file does a full quit→relaunch cycle, which spawns
// two Electron instances back-to-back. Running in serial keeps the isolated
// userDataDirs from competing for the same Electron cache lock on cold start
// and keeps the failure mode interpretable when something goes wrong.
test.describe.configure({ mode: 'serial' })
function seededRepoPathOrSkip(): string {
const repoPath = existsSync(TEST_REPO_PATH_FILE)
? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
: ''
const unavailable = !repoPath || !existsSync(repoPath)
if (unavailable && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) {
throw new Error('Required Windows restart E2E seeded repo is unavailable')
}
test.skip(unavailable, MISSING_SEEDED_REPO_MESSAGE)
return repoPath
}
/**
* Shared bootstrap for a *first* launch: attach the seeded test repo,
* activate its worktree, ensure a terminal is mounted, and return the
@ -75,6 +91,9 @@ async function bootstrapFirstLaunch(
const hasPaneManager = await waitForActiveTerminalManager(page, 30_000)
.then(() => true)
.catch(() => false)
if (!hasPaneManager && REQUIRE_WINDOWS_TERMINAL_RESTART_E2E) {
throw new Error('Required Windows restart E2E TerminalPane manager did not mount')
}
test.skip(
!hasPaneManager,
'Electron automation in this environment never mounts the TerminalPane manager, so restart-persistence assertions would only fail on harness setup.'
@ -189,11 +208,7 @@ async function expectSavedLayoutToContainTitle(
test.describe('Terminal restart persistence', () => {
test('scrollback survives clean quit and relaunch', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
@ -249,11 +264,7 @@ test.describe('Terminal restart persistence', () => {
test('daemon snapshot relaunch preserves the cursor on the shell prompt', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
@ -267,7 +278,13 @@ test.describe('Terminal restart persistence', () => {
const prompt = `ORCA_RESTART_PROMPT_${Date.now()}_GT `
const marker = `ORCA_CURSOR_RESTART_${Date.now()}`
await execInTerminal(firstLaunch.page, ptyId, `export PS1='${prompt}'; PROMPT='${prompt}'`)
const promptCommand =
process.platform === 'win32'
? `function global:prompt { '${prompt}' }`
: `export PS1='${prompt}'; PROMPT='${prompt}'`
// Why: the Windows default shell is PowerShell, whose prompt is a
// function; PS1/PROMPT assignments remain the Bash/Zsh path.
await execInTerminal(firstLaunch.page, ptyId, promptCommand)
await waitForTerminalActiveLine(firstLaunch.page, prompt.trim())
await execInTerminal(firstLaunch.page, ptyId, `echo ${marker}`)
await waitForTerminalOutput(firstLaunch.page, marker)
@ -313,11 +330,7 @@ test.describe('Terminal restart persistence', () => {
test('active worktree and terminal tab count survive restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
@ -378,11 +391,7 @@ test.describe('Terminal restart persistence', () => {
test('restored Set Title pane label survives agent title churn', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let firstApp: ElectronApplication | null = null
@ -451,11 +460,7 @@ test.describe('Terminal restart persistence', () => {
test('idle session does not spam session.set writes', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
if (!repoPath || !existsSync(repoPath)) {
test.skip(true, 'Global setup did not produce a seeded test repo')
return
}
const repoPath = seededRepoPathOrSkip()
const session = createRestartSession(testInfo)
let app: ElectronApplication | null = null