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:
parent
546cc8237f
commit
803a442868
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue