orca/tests/e2e/paired-remote-terminal-park...

485 lines
17 KiB
TypeScript

/**
* Paired remote server: a revealed remote terminal must stay interactive
* without a tab flip.
*
* Topology: headed Orca desktop host (remote server) + a separate paired Orca
* desktop client — the "connect to Windows 2, open an old workspace" shape.
*
* Oracle (the reported symptom verbatim): type into the revealed pane and see
* the echo paint live. "Paints only after switching to another terminal and
* back" is the failure, so each scenario records both.
*
* The decoy tabs are load-bearing, not scenery. Pre-fix, handleClose never runs,
* so closeIfIdle is the only remaining release path and it needs zero streams —
* a sibling stream is what keeps the wedged multiplexer alive and the pre-fix
* state red. Each scenario asserts its flip decoy is still mounted at the reveal
* so that invariant cannot quietly lapse and turn this green.
*
* Run:
* pnpm exec playwright test \
* tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts \
* --config tests/playwright.config.ts --project electron-headless --workers=1
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import {
HOST_TERMINAL_SURFACE_SEPARATOR,
toWebTerminalSurfaceTabId
} from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedElectronClient,
type PairedElectronClient
} from './helpers/paired-electron-client'
import { focusActiveTerminalInput } from './helpers/terminal'
import { waitForTabParked } from './helpers/terminal-hidden-parking'
const PARK_DELAY_MS = 2_000
const LIVE_PAINT_BUDGET_MS = 12_000
const REVEAL_BUDGET_MS = 20_000
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-parked-reveal-'))
const fixturePath = path.join(scratch, 'parked-reveal-terminal.mjs')
writeFileSync(
fixturePath,
[
"import { appendFileSync } from 'node:fs'",
'const sink = process.argv[2]',
'const size = () => `${process.stdout.columns}x${process.stdout.rows}`',
'const record = (line) => appendFileSync(sink, `${line}\\n`)',
'record(`READY:${size()}`)',
'process.stdout.write(`READY:${size()}\\r\\n`)',
"process.stdout.on('resize', () => {",
' record(`SIZE:${size()}`)',
' process.stdout.write(`SIZE:${size()}\\r\\n`)',
'})',
"process.stdin.setEncoding('utf8')",
"let pending = ''",
"process.stdin.on('data', (data) => {",
' pending += data',
' const lines = pending.split(/\\r\\n|\\r|\\n/)',
" pending = lines.pop() ?? ''",
' for (const line of lines) {',
' record(`LINE:${line}`)',
' process.stdout.write(`LINE:${line}\\r\\n`)',
' }',
'})',
'process.stdin.resume()'
].join('\n')
)
test.afterAll(() => {
rmSync(scratch, { recursive: true, force: true })
})
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function fixtureCommand(sinkPath: string): string {
const command = [process.execPath, fixturePath, sinkPath]
return process.platform === 'win32'
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
: command.map(shellQuote).join(' ')
}
function readSink(sinkPath: string): string {
try {
return readFileSync(sinkPath, 'utf8')
} catch {
return ''
}
}
async function callEnvironment<TResult>(
page: Page,
environmentId: string,
method: string,
params: unknown
): Promise<TResult> {
return page.evaluate(
async ({ environmentId, method, params }) => {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method,
params
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
},
{ environmentId, method, params }
) as Promise<TResult>
}
type HostTerminal = {
hostTabId: string
sinkPath: string
terminal: string
webTabId: string
}
async function createHostTerminal(
page: Page,
environmentId: string,
worktreeId: string
): Promise<HostTerminal> {
const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`)
const result = await callEnvironment<{ tab: { id: string; terminal: string | null } }>(
page,
environmentId,
'session.tabs.createTerminal',
{
worktree: `id:${worktreeId}`,
command: fixtureCommand(sinkPath),
activate: false,
select: false,
navigation: 'caller'
}
)
if (!result.tab.terminal) {
throw new Error('host session terminal was not created')
}
// Why: the host answers with a `tabId::leafId` surface id; client tabs mirror the parent tab.
const hostTabId = result.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]
return {
hostTabId,
sinkPath,
terminal: result.tab.terminal,
webTabId: toWebTerminalSurfaceTabId(hostTabId)
}
}
async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise<void> {
await expect
.poll(
() =>
page.evaluate(
(id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id),
worktreeId
),
{ timeout: 60_000, message: `client never mirrored host tab ${webTabId}` }
)
.toContain(webTabId)
await page.evaluate(
({ webTabId, worktreeId }) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(worktreeId)
state?.setActiveTab(webTabId)
state?.setActiveTabType('terminal')
},
{ webTabId, worktreeId }
)
await expect
.poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), {
timeout: 60_000,
message: `client pane for ${webTabId} did not mount`
})
.toBe(true)
}
async function readActivePaneGrid(
page: Page,
webTabId: string
): Promise<{ cols: number; rows: number } | null> {
return page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane ? { cols: pane.terminal.cols, rows: pane.terminal.rows } : null
}, webTabId)
}
/** Reads the target tab's own buffer. `getTerminalContent` resolves whatever
* tab the store thinks is active, which hides per-tab reveal failures. */
async function readPaneContent(page: Page, webTabId: string): Promise<string> {
return page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.serializeAddon?.serialize?.() ?? ''
}, webTabId)
}
async function readPaneDiagnostics(
page: Page,
worktreeId: string,
webTabId: string
): Promise<unknown> {
return page.evaluate(
({ webTabId, worktreeId }) => {
const manager = window.__paneManagers?.get(webTabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const state = window.__store?.getState()
const tab = (state?.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === webTabId)
return {
mounted: Boolean(manager),
ptyId: pane?.container?.dataset?.ptyId ?? null,
recoveryState: pane?.container?.dataset?.ptyRecoveryState ?? null,
cols: pane?.terminal?.cols ?? null,
rows: pane?.terminal?.rows ?? null,
bufferLength: pane?.serializeAddon?.serialize?.()?.length ?? null,
paneLeafIds: manager?.getPanes?.().map((entry) => entry.leafId ?? null) ?? null,
storeTabPtyId: tab?.ptyId ?? null,
storeTabLayout: tab?.paneLayout ? JSON.stringify(tab.paneLayout) : null,
storePtyIdsByTab: state?.ptyIdsByTabId?.[webTabId] ?? null
}
},
{ webTabId, worktreeId }
)
}
async function waitForPaneMarker(
page: Page,
webTabId: string,
marker: string,
budgetMs: number
): Promise<boolean> {
const deadline = Date.now() + budgetMs
while (Date.now() < deadline) {
if ((await readPaneContent(page, webTabId)).includes(marker)) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
}
function readPtyGridFromContent(content: string): { cols: number; rows: number } | null {
const sizes = [...content.matchAll(/(?:READY|SIZE):(\d+)x(\d+)/g)]
const last = sizes.at(-1)
return last ? { cols: Number(last[1]), rows: Number(last[2]) } : null
}
type ScenarioResult = {
name: string
restoredBuffer: boolean
hostReceivedInput: boolean
paintedLive: boolean
paintedAfterFlip: boolean
paneGrid: { cols: number; rows: number } | null
ptyGrid: { cols: number; rows: number } | null
diagnostics: unknown
}
/** Types a unique marker into the revealed pane and records whether the host
* received it, whether it painted live, and (if not) whether the reported
* tab-flip workaround reveals it. */
async function probeInteractivity(
page: Page,
worktreeId: string,
target: HostTerminal,
flipTo: HostTerminal,
name: string
): Promise<ScenarioResult> {
const token = `probe-${name}`
// Why: a human types once the pane looks restored; typing earlier would race the reattach.
const restoredBuffer = await waitForPaneMarker(page, target.webTabId, 'READY:', REVEAL_BUDGET_MS)
await focusActiveTerminalInput(page)
await page.keyboard.type(token)
await page.keyboard.press('Enter')
const paintedLive = await waitForPaneMarker(
page,
target.webTabId,
`LINE:${token}`,
LIVE_PAINT_BUDGET_MS
)
const paneGrid = await readActivePaneGrid(page, target.webTabId)
const diagnostics = await readPaneDiagnostics(page, worktreeId, target.webTabId)
let paintedAfterFlip = paintedLive
if (!paintedLive) {
await openClientTab(page, worktreeId, flipTo.webTabId)
await openClientTab(page, worktreeId, target.webTabId)
paintedAfterFlip = await waitForPaneMarker(
page,
target.webTabId,
`LINE:${token}`,
LIVE_PAINT_BUDGET_MS
)
}
const sink = readSink(target.sinkPath)
return {
name,
restoredBuffer,
hostReceivedInput: sink.includes(`LINE:${token}`),
paintedLive,
paintedAfterFlip,
paneGrid,
ptyGrid: readPtyGridFromContent(sink),
diagnostics
}
}
/** The wedged-multiplexer repro needs a sibling stream alive at reveal time
* (see the header), and S1 additionally needs its target never to have parked. */
async function expectStillMounted(page: Page, webTabId: string, label: string): Promise<void> {
expect(
await page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId),
`${label} parked before the reveal it is supposed to survive`
).toBe(true)
}
/** Logged unconditionally: on failure this line is the whole diagnosis. */
function logResult(result: ScenarioResult): ScenarioResult {
console.log(`[paired-reveal] ${JSON.stringify(result)}`)
return result
}
async function seedScenario(
client: PairedElectronClient,
worktreeId: string
): Promise<{ target: HostTerminal; decoys: HostTerminal[] }> {
const target = await createHostTerminal(client.page, client.environmentId, worktreeId)
const decoys = [
await createHostTerminal(client.page, client.environmentId, worktreeId),
await createHostTerminal(client.page, client.environmentId, worktreeId)
]
await openClientTab(client.page, worktreeId, target.webTabId)
await expect
.poll(() => readPaneContent(client.page, target.webTabId), {
timeout: 60_000,
message: 'target terminal never painted its READY marker'
})
.toContain('READY:')
return { target, decoys }
}
test('paired client keeps revealed remote terminals interactive', async ({
orcaPage
}, testInfo) => {
test.setTimeout(600_000)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
// Why: the paired client inherits this from the launching process; a reused
// Playwright worker would otherwise leak the shortened delay into later specs.
const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS)
const client = await launchPairedElectronClient(offer, testInfo, 'parked-reveal')
const createdTerminals: string[] = []
const results: ScenarioResult[] = []
try {
const worktreeId = await orcaPage.evaluate(() => {
const id = window.__store?.getState().activeWorktreeId
if (!id) {
throw new Error('headed host has no active worktree')
}
return id
})
await expect
.poll(
() =>
client.page.evaluate(
(id) =>
window.__store
?.getState()
.allWorktrees()
.some((worktree) => worktree.id === id) ?? false,
worktreeId
),
{ timeout: 60_000, message: 'paired client never saw the host worktree' }
)
.toBe(true)
await client.page.evaluate((id) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(id)
}, worktreeId)
// S1 — hidden tab that stays mounted, then revealed.
{
const { target, decoys } = await seedScenario(client, worktreeId)
createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal))
await openClientTab(client.page, worktreeId, decoys[0].webTabId)
// Pins the scenario label: this reveal must not have gone through a park.
await expectStillMounted(client.page, target.webTabId, 'hidden-mounted target')
await openClientTab(client.page, worktreeId, target.webTabId)
results.push(
logResult(
await probeInteractivity(client.page, worktreeId, target, decoys[1], 'hidden-mounted')
)
)
}
// S2 — cold-parked tab (renderer unmounted), then revealed.
{
const { target, decoys } = await seedScenario(client, worktreeId)
createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal))
await openClientTab(client.page, worktreeId, decoys[0].webTabId)
await openClientTab(client.page, worktreeId, decoys[1].webTabId)
await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS })
await expectStillMounted(client.page, decoys[1].webTabId, 'cold-parked flip decoy')
await openClientTab(client.page, worktreeId, target.webTabId)
results.push(
logResult(
await probeInteractivity(client.page, worktreeId, target, decoys[1], 'cold-parked')
)
)
}
// S3 — cold-parked tab whose runtime connection dropped and came back
// while parked (the "returned after a while" report).
{
const { target, decoys } = await seedScenario(client, worktreeId)
createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal))
await openClientTab(client.page, worktreeId, decoys[0].webTabId)
await openClientTab(client.page, worktreeId, decoys[1].webTabId)
await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS })
await client.page.evaluate(async (selector) => {
await window.api.runtimeEnvironments.disconnect({ selector })
}, client.environmentId)
await expect
.poll(
async () =>
client.page.evaluate(async (selector) => {
const response = await window.api.runtimeEnvironments.connect({ selector })
return response.ok
}, client.environmentId),
{ timeout: 60_000, message: 'paired client never reconnected to the host runtime' }
)
.toBe(true)
await expectStillMounted(client.page, decoys[1].webTabId, 'reconnect-parked flip decoy')
await openClientTab(client.page, worktreeId, target.webTabId)
results.push(
logResult(
await probeInteractivity(client.page, worktreeId, target, decoys[1], 'reconnect-parked')
)
)
}
for (const result of results) {
expect(
{
scenario: result.name,
restoredBuffer: result.restoredBuffer,
hostReceivedInput: result.hostReceivedInput,
paintedLive: result.paintedLive
},
`${result.name}: revealed pane was not interactive (painted after tab flip: ${result.paintedAfterFlip})`
).toEqual({
scenario: result.name,
restoredBuffer: true,
hostReceivedInput: true,
paintedLive: true
})
expect(
{ scenario: result.name, ptyGrid: result.ptyGrid },
`${result.name}: host PTY geometry never converged on the revealed pane grid`
).toEqual({ scenario: result.name, ptyGrid: result.paneGrid })
}
} finally {
if (previousParkDelay === undefined) {
delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
} else {
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay
}
for (const terminal of createdTerminals) {
await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', {
terminal
}).catch(() => undefined)
}
await client.dispose()
}
})