From 803a4428686338ce79b7f96cd78fbfc25044b33c Mon Sep 17 00:00:00 2001 From: slashdevcorpse <73707049+slashdevcorpse@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:18:36 -0400 Subject: [PATCH] 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> --- .../windows-terminal-restart-e2e.yml | 103 +++++++++ .../restored-terminal-input-readiness.ts | 80 +++++++ tests/e2e/helpers/terminal-input-probes.ts | 25 ++- tests/e2e/helpers/terminal.ts | 13 +- .../restart-restore-terminal-input.spec.ts | 71 +++++- ...ored-terminal-input-readiness.unit.test.ts | 210 ++++++++++++++++++ tests/e2e/terminal-probe-input-sequence.ts | 9 + ...terminal-probe-input-sequence.unit.test.ts | 20 +- .../e2e/terminal-restart-persistence.spec.ts | 57 ++--- 9 files changed, 543 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/windows-terminal-restart-e2e.yml create mode 100644 tests/e2e/helpers/restored-terminal-input-readiness.ts create mode 100644 tests/e2e/restored-terminal-input-readiness.unit.test.ts diff --git a/.github/workflows/windows-terminal-restart-e2e.yml b/.github/workflows/windows-terminal-restart-e2e.yml new file mode 100644 index 000000000..73543b5e0 --- /dev/null +++ b/.github/workflows/windows-terminal-restart-e2e.yml @@ -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 diff --git a/tests/e2e/helpers/restored-terminal-input-readiness.ts b/tests/e2e/helpers/restored-terminal-input-readiness.ts new file mode 100644 index 000000000..c54bb8e10 --- /dev/null +++ b/tests/e2e/helpers/restored-terminal-input-readiness.ts @@ -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 { + 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 +} diff --git a/tests/e2e/helpers/terminal-input-probes.ts b/tests/e2e/helpers/terminal-input-probes.ts index 82fdd2678..9d6b45457 100644 --- a/tests/e2e/helpers/terminal-input-probes.ts +++ b/tests/e2e/helpers/terminal-input-probes.ts @@ -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 { - // \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 { try { + const inputs = buildSettledShellProbeInputSequence(`echo ${marker}\r`) const fed = await mainRendererEval( 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 { try { + const inputs = buildSettledShellProbeInputSequence(`echo ${marker}\r`) await mainRendererEval( 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)}` diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index fa4f703a1..52d32e2b0 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -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 { 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 diff --git a/tests/e2e/restart-restore-terminal-input.spec.ts b/tests/e2e/restart-restore-terminal-input.spec.ts index 2ded33f0c..1ceed4254 100644 --- a/tests/e2e/restart-restore-terminal-input.spec.ts +++ b/tests/e2e/restart-restore-terminal-input.spec.ts @@ -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 { + 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 { */ async function expectRestoredPaneAcceptsInput(page: Page, context: string): Promise { 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 diff --git a/tests/e2e/restored-terminal-input-readiness.unit.test.ts b/tests/e2e/restored-terminal-input-readiness.unit.test.ts new file mode 100644 index 000000000..8eef3321f --- /dev/null +++ b/tests/e2e/restored-terminal-input-readiness.unit.test.ts @@ -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]) + }) +}) diff --git a/tests/e2e/terminal-probe-input-sequence.ts b/tests/e2e/terminal-probe-input-sequence.ts index 854020780..f6dd532e1 100644 --- a/tests/e2e/terminal-probe-input-sequence.ts +++ b/tests/e2e/terminal-probe-input-sequence.ts @@ -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] +} diff --git a/tests/e2e/terminal-probe-input-sequence.unit.test.ts b/tests/e2e/terminal-probe-input-sequence.unit.test.ts index 41e2cd825..e50f69fe6 100644 --- a/tests/e2e/terminal-probe-input-sequence.unit.test.ts +++ b/tests/e2e/terminal-probe-input-sequence.unit.test.ts @@ -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]) + }) +}) diff --git a/tests/e2e/terminal-restart-persistence.spec.ts b/tests/e2e/terminal-restart-persistence.spec.ts index 19cccc43c..b9cbd6a00 100644 --- a/tests/e2e/terminal-restart-persistence.spec.ts +++ b/tests/e2e/terminal-restart-persistence.spec.ts @@ -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