mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
Merge remote-tracking branch 'origin/nwparker/adv3-journey-fixes' into nwparker/j4-upgrade-downgrade-stack-v2
# Conflicts: # src/renderer/src/lib/host-mirror-handle-gap-wait.ts
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* JOURNEY: quit the desktop app while remote terminals are live on the host, then reopen it.
|
||||
*
|
||||
* TOPOLOGY: the `orcaPage` app is the host (orca server); a separate real Orca desktop client
|
||||
* pairs to it, opens a host terminal, works in it, is force-quit, and relaunched on the same
|
||||
* profile — the pairing credential and the persisted session survive, as they do for a real
|
||||
* force-quit reopen.
|
||||
*
|
||||
* Why this exists: every paired restart spec in this suite restarts around a *browser* pane
|
||||
* (paired-client-hosted-browser-*.spec.ts). None of them restarts a client holding a live remote
|
||||
* *terminal*, which is the thing the user is actually mid-work in.
|
||||
*
|
||||
* The terminal is a fixture that appends one line per event to a file on disk. That sink is the
|
||||
* oracle nothing on the client can fake:
|
||||
* - exactly one `READY` for the whole run means the host never re-spawned the process, so the
|
||||
* user came back to their session rather than a fresh shell wearing its name;
|
||||
* - a `LINE:` for input sent after the relaunch means the restored pane is wired to that same
|
||||
* process, not merely painted with its scrollback.
|
||||
*
|
||||
* Run:
|
||||
* pnpm exec playwright test \
|
||||
* tests/e2e/paired-remote-terminal-client-restart-survival.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 { closeElectronAppForE2E } from './helpers/electron-process-shutdown'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
createRuntimeDesktopPairingOffer,
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
/** What a user would accept for "my terminal is back" after reopening the app. */
|
||||
const RESTORE_BUDGET_MS = 60_000
|
||||
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-client-restart-survival-'))
|
||||
const fixturePath = path.join(scratch, 'restart-survival-terminal.mjs')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
"import { appendFileSync } from 'node:fs'",
|
||||
'const sink = process.argv[2]',
|
||||
'const record = (line) => appendFileSync(sink, `${line}\\n`)',
|
||||
"record('READY')",
|
||||
"process.stdout.write('RESTART_SURVIVAL_READY\\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 readSinkLines(sinkPath: string): string[] {
|
||||
try {
|
||||
return readFileSync(sinkPath, 'utf8').split('\n').filter(Boolean)
|
||||
} 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>
|
||||
}
|
||||
|
||||
async function focusWorkspace(page: Page, worktreeId: string): Promise<void> {
|
||||
await page.evaluate((id) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(id)
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function waitForClientWorkspace(page: Page, worktreeId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
(id) => (window.__store?.getState().allWorktrees() ?? []).some((w) => w.id === id),
|
||||
worktreeId
|
||||
),
|
||||
{ timeout: 60_000, message: 'paired client never received the host workspace' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
/** Milliseconds until the tab is mirrored again, or null if it never was. */
|
||||
async function waitForMirroredTab(
|
||||
page: Page,
|
||||
worktreeId: string,
|
||||
webTabId: string,
|
||||
budgetMs: number
|
||||
): Promise<number | null> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
const present = await page.evaluate(
|
||||
({ id, worktreeId }) =>
|
||||
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === id),
|
||||
{ id: webTabId, worktreeId }
|
||||
)
|
||||
if (present) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Milliseconds until the restored pane paints `marker`, or null if it never did. */
|
||||
async function waitForPanePaint(
|
||||
page: Page,
|
||||
webTabId: string,
|
||||
marker: string,
|
||||
budgetMs: number
|
||||
): Promise<number | null> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
const content = await page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
return pane?.serializeAddon?.serialize?.() ?? ''
|
||||
}, webTabId)
|
||||
if (content.includes(marker)) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function selectClientTab(page: Page, worktreeId: string, webTabId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ webTabId, worktreeId }) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(worktreeId)
|
||||
state?.setActiveTab(webTabId)
|
||||
state?.setActiveTabType('terminal')
|
||||
},
|
||||
{ webTabId, worktreeId }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Types `marker` into the pane until the host-side process records it, or the budget ends.
|
||||
*
|
||||
* Why through `pane.terminal.input` and not `window.api.pty.write`: a mirrored pane's handle is
|
||||
* a `remote:` id that no local PTY answers to, so a direct write is silently swallowed. This is
|
||||
* the path a keystroke actually takes, and it is retried because a pane still reattaching can
|
||||
* replay-suppress a write (helpers/restored-terminal-input-readiness.ts polls for that reason).
|
||||
*/
|
||||
async function driveInputUntilProcessSees(
|
||||
client: PairedElectronClient,
|
||||
webTabId: string,
|
||||
sinkPath: string,
|
||||
marker: string,
|
||||
budgetMs: number
|
||||
): Promise<boolean> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
await client.page.evaluate(
|
||||
({ id, text }) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
pane?.terminal?.input?.(text, true)
|
||||
},
|
||||
{ id: webTabId, text: `${marker}\r` }
|
||||
)
|
||||
if (readSinkLines(sinkPath).some((line) => line.includes(marker))) {
|
||||
return true
|
||||
}
|
||||
await client.page.waitForTimeout(1_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function readTabPtyIds(client: PairedElectronClient, webTabId: string): Promise<string[]> {
|
||||
return client.page.evaluate((id) => window.__store?.getState().ptyIdsByTabId[id] ?? [], webTabId)
|
||||
}
|
||||
|
||||
test('a relaunched client gets its live remote terminal back, still attached to the same process', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(900_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const worktreeId = await orcaPage.evaluate(() => {
|
||||
const id = window.__store?.getState().activeWorktreeId
|
||||
if (!id) {
|
||||
throw new Error('host has no active worktree')
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`)
|
||||
const failures: string[] = []
|
||||
let client: PairedElectronClient | null = null
|
||||
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
|
||||
try {
|
||||
client = await launchPairedElectronClient(offer, testInfo, 'remote-terminal-restart-survival')
|
||||
const userDataDir = client.userDataDir
|
||||
await waitForClientWorkspace(client.page, worktreeId)
|
||||
await focusWorkspace(client.page, worktreeId)
|
||||
|
||||
const created = await callEnvironment<{ tab: { id: string; terminal: string | null } }>(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
command: fixtureCommand(sinkPath),
|
||||
activate: true,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
}
|
||||
)
|
||||
const hostTabId = created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]!
|
||||
const webTabId = toWebTerminalSurfaceTabId(hostTabId)
|
||||
expect(
|
||||
await waitForMirroredTab(client.page, worktreeId, webTabId, RESTORE_BUDGET_MS),
|
||||
'the client never mirrored the terminal it created'
|
||||
).not.toBeNull()
|
||||
await selectClientTab(client.page, worktreeId, webTabId)
|
||||
await expect
|
||||
.poll(() => readSinkLines(sinkPath), {
|
||||
timeout: RESTORE_BUDGET_MS,
|
||||
message: 'the host terminal fixture never started'
|
||||
})
|
||||
.toContain('READY')
|
||||
expect(
|
||||
await waitForPanePaint(client.page, webTabId, 'RESTART_SURVIVAL_READY', RESTORE_BUDGET_MS),
|
||||
'the pane never painted the live terminal before the restart'
|
||||
).not.toBeNull()
|
||||
|
||||
// The control. Without it, "input did not arrive after the restart" cannot be told apart
|
||||
// from "this input path never worked in this topology".
|
||||
const ptyIdsBefore = await readTabPtyIds(client, webTabId)
|
||||
expect(ptyIdsBefore, 'the live pane had no PTY handle before the restart').not.toHaveLength(0)
|
||||
expect(
|
||||
await driveInputUntilProcessSees(
|
||||
client,
|
||||
webTabId,
|
||||
sinkPath,
|
||||
'PRE_RESTART_CONTROL',
|
||||
RESTORE_BUDGET_MS
|
||||
),
|
||||
'input did not reach the host process even before the restart — the probe, not the product'
|
||||
).toBe(true)
|
||||
|
||||
// ── The restart: force-quit and reopen on the same profile. ──
|
||||
// Quit without disposing: the profile has to outlive the app, as it does for a real Cmd+Q.
|
||||
const quitting = client.app
|
||||
client = null
|
||||
await closeElectronAppForE2E(quitting)
|
||||
client = await launchPairedElectronClient(
|
||||
offer,
|
||||
testInfo,
|
||||
'remote-terminal-restart-survival-relaunch',
|
||||
{ reuseUserDataDir: userDataDir }
|
||||
)
|
||||
await waitForClientWorkspace(client.page, worktreeId)
|
||||
await focusWorkspace(client.page, worktreeId)
|
||||
|
||||
const tabBackMs = await waitForMirroredTab(client.page, worktreeId, webTabId, RESTORE_BUDGET_MS)
|
||||
console.error(`[client-restart] tabBackMs=${tabBackMs}`)
|
||||
if (tabBackMs === null) {
|
||||
failures.push('the remote terminal tab never came back after the app was reopened')
|
||||
} else {
|
||||
await selectClientTab(client.page, worktreeId, webTabId)
|
||||
const paintedMs = await waitForPanePaint(
|
||||
client.page,
|
||||
webTabId,
|
||||
'RESTART_SURVIVAL_READY',
|
||||
RESTORE_BUDGET_MS
|
||||
)
|
||||
console.error(`[client-restart] paintedMs=${paintedMs}`)
|
||||
if (paintedMs === null) {
|
||||
failures.push(
|
||||
'the remote terminal came back empty — the tab is there but the transcript is not'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Is the restored pane actually wired to the live process, or only painted with its past?
|
||||
const marker = `POST_RESTART_${randomUUID().slice(0, 8)}`
|
||||
const ptyIds = await readTabPtyIds(client, webTabId)
|
||||
console.error(`[client-restart] ptyBefore=${ptyIdsBefore[0]} ptyAfter=${ptyIds[0] ?? 'none'}`)
|
||||
if (ptyIds.length === 0) {
|
||||
failures.push(
|
||||
'the restored tab has no PTY handle — nothing the user types can reach the host'
|
||||
)
|
||||
} else {
|
||||
const echoed = await driveInputUntilProcessSees(
|
||||
client,
|
||||
webTabId,
|
||||
sinkPath,
|
||||
marker,
|
||||
RESTORE_BUDGET_MS
|
||||
)
|
||||
console.error(`[client-restart] inputReachedProcess=${echoed}`)
|
||||
if (!echoed) {
|
||||
failures.push(
|
||||
'input typed into the restored terminal never reached the process the host is running'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The sink is the fork oracle: a second READY means the host re-spawned the user's work.
|
||||
const readyCount = readSinkLines(sinkPath).filter((line) => line === 'READY').length
|
||||
console.error(`[client-restart] readyCount=${readyCount}`)
|
||||
if (readyCount !== 1) {
|
||||
failures.push(
|
||||
`the host process was re-spawned across the client restart (READY x${readyCount}) — the user's session was replaced, not restored`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await client?.dispose()
|
||||
}
|
||||
expect(failures, failures.join('\n')).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* JOURNEY: two desktop clients paired to one Orca server, working in the same workspace.
|
||||
*
|
||||
* TOPOLOGY: the `orcaPage` app is the host (orca server). Two separate real Orca desktop
|
||||
* clients pair to it, exactly as two of the user's machines would. Nothing is faulted — this
|
||||
* is the ordinary shape of using Orca from a laptop and a desktop at the same time.
|
||||
*
|
||||
* The emptied-workspace tombstone is an explicit `tabsByWorktree[worktreeId] = []` row and it
|
||||
* is client-local on the runtime path: it never crosses the wire, so the second client cannot
|
||||
* know the first emptied the workspace on purpose and still seeds into it. That asymmetry is
|
||||
* by design. What is NOT by design is a client falling out of step with the host and staying
|
||||
* there, which is what this spec measures.
|
||||
*
|
||||
* Phase 0 is the control: with both clients attached, does a terminal created on one reach the
|
||||
* other at all? Without it a later divergence cannot be attributed to the emptying.
|
||||
*
|
||||
* KNOWN RED as of this commit, and the shape of the failure is the finding. Across 8 runs the
|
||||
* close phases were all-or-nothing: either every retraction reached both clients in single-digit
|
||||
* milliseconds, or none reached either client within 90 seconds — and phase 1a, which closes a
|
||||
* terminal while others remain open, fails alongside phase 1b, so it is not about the workspace
|
||||
* going empty. Creates always propagate, including the phase 2 create that lands in ~3ms on the
|
||||
* very clients that just missed a close for 90s, so the subscription is demonstrably alive. Both
|
||||
* clients failing together, while the host's own window shows the correct count, puts the fault
|
||||
* on the host's publish-after-close rather than on any client's mirror. What the user sees: a
|
||||
* terminal they closed on one machine stays in the tab bar on the other, pointing at a process
|
||||
* that no longer exists, until some unrelated change to the workspace forces a republish.
|
||||
*
|
||||
* Run:
|
||||
* pnpm exec playwright test \
|
||||
* tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts \
|
||||
* --config tests/playwright.config.ts --project electron-headless --workers=1
|
||||
*/
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
createRuntimeDesktopPairingOffer,
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
/** How long a client may lag the host before the user would call it broken. */
|
||||
const MIRROR_BUDGET_MS = 30_000
|
||||
/** A retraction may be slow; what matters is whether it arrives at all. */
|
||||
const RETRACTION_BUDGET_MS = 90_000
|
||||
|
||||
type HostTabRow = { id: string; parentTabId?: string; terminal?: string | null }
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
/** The host's own tab inventory — the only oracle that is not a client re-derivation. */
|
||||
async function readHostTerminalTabIds(
|
||||
client: PairedElectronClient,
|
||||
worktreeId: string
|
||||
): Promise<string[]> {
|
||||
const inventory = await callEnvironment<{ tabs: HostTabRow[] }>(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
'session.tabs.list',
|
||||
{ worktree: `id:${worktreeId}` }
|
||||
)
|
||||
return [
|
||||
...new Set(
|
||||
inventory.tabs
|
||||
.filter((tab) => tab.terminal !== undefined && tab.terminal !== null)
|
||||
.map((tab) => tab.parentTabId ?? tab.id)
|
||||
)
|
||||
].sort()
|
||||
}
|
||||
|
||||
async function readMirroredTabCount(page: Page, worktreeId: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
(id) => (window.__store?.getState().tabsByWorktree[id] ?? []).length,
|
||||
worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the client holds an explicit empty row (the tombstone) versus no row at all. */
|
||||
async function readWorkspaceRowState(
|
||||
page: Page,
|
||||
worktreeId: string
|
||||
): Promise<'missing' | 'tombstoned' | 'populated'> {
|
||||
return page.evaluate((id) => {
|
||||
const tabs = window.__store?.getState().tabsByWorktree
|
||||
if (!tabs || !Object.hasOwn(tabs, id)) {
|
||||
return 'missing' as const
|
||||
}
|
||||
return (tabs[id] ?? []).length === 0 ? ('tombstoned' as const) : ('populated' as const)
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function focusWorkspace(page: Page, worktreeId: string): Promise<void> {
|
||||
await page.evaluate((id) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(id)
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
/** Milliseconds until the client's mirrored count matches the host's, or null if it never did. */
|
||||
async function waitForClientToMatchHost(
|
||||
client: PairedElectronClient,
|
||||
hostCount: number,
|
||||
worktreeId: string,
|
||||
budgetMs: number
|
||||
): Promise<number | null> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < budgetMs) {
|
||||
if ((await readMirroredTabCount(client.page, worktreeId)) === hostCount) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await client.page.waitForTimeout(500)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function waitForClientWorkspace(page: Page, worktreeId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
(id) => (window.__store?.getState().allWorktrees() ?? []).some((w) => w.id === id),
|
||||
worktreeId
|
||||
),
|
||||
{ timeout: 60_000, message: 'paired client never received the host workspace' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test('two paired clients stay in step with the host across an emptied workspace', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(600_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const worktreeId = await orcaPage.evaluate(() => {
|
||||
const id = window.__store?.getState().activeWorktreeId
|
||||
if (!id) {
|
||||
throw new Error('host has no active worktree')
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
let clientA: PairedElectronClient | null = null
|
||||
let clientB: PairedElectronClient | null = null
|
||||
const failures: string[] = []
|
||||
try {
|
||||
clientA = await launchPairedElectronClient(
|
||||
await createRuntimeDesktopPairingOffer(orcaPage),
|
||||
testInfo,
|
||||
'emptied-workspace-client-a'
|
||||
)
|
||||
clientB = await launchPairedElectronClient(
|
||||
await createRuntimeDesktopPairingOffer(orcaPage),
|
||||
testInfo,
|
||||
'emptied-workspace-client-b'
|
||||
)
|
||||
for (const client of [clientA, clientB]) {
|
||||
await waitForClientWorkspace(client.page, worktreeId)
|
||||
await focusWorkspace(client.page, worktreeId)
|
||||
}
|
||||
|
||||
// ── Phase 0: the control. A creates a terminal; B must see it. ──
|
||||
await callEnvironment(clientA.page, clientA.environmentId, 'session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: true,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
const afterCreate = (await readHostTerminalTabIds(clientA, worktreeId)).length
|
||||
const controlA = await waitForClientToMatchHost(
|
||||
clientA,
|
||||
afterCreate,
|
||||
worktreeId,
|
||||
MIRROR_BUDGET_MS
|
||||
)
|
||||
const controlB = await waitForClientToMatchHost(
|
||||
clientB,
|
||||
afterCreate,
|
||||
worktreeId,
|
||||
MIRROR_BUDGET_MS
|
||||
)
|
||||
console.error(`[two-client] phase0 host=${afterCreate} A=${controlA}ms B=${controlB}ms`)
|
||||
if (controlA === null || controlB === null) {
|
||||
failures.push(
|
||||
`phase0: a terminal created on one client never reached the other (host=${afterCreate}, A=${controlA}, B=${controlB})`
|
||||
)
|
||||
}
|
||||
|
||||
// ── Phase 1a: A closes one terminal, but not the last one. ──
|
||||
// Separated from the emptying below on purpose: it is the control that says whether a
|
||||
// retraction propagates at all, so a failure in 1b can be attributed to the workspace going
|
||||
// empty rather than to close retractions being broken in general.
|
||||
const beforePartialClose = await readHostTerminalTabIds(clientA, worktreeId)
|
||||
if (beforePartialClose.length > 1) {
|
||||
await callEnvironment(clientA.page, clientA.environmentId, 'session.tabs.close', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: beforePartialClose[0]!,
|
||||
reason: 'user',
|
||||
navigation: 'caller'
|
||||
})
|
||||
const remaining = beforePartialClose.length - 1
|
||||
const partialA = await waitForClientToMatchHost(
|
||||
clientA,
|
||||
remaining,
|
||||
worktreeId,
|
||||
RETRACTION_BUDGET_MS
|
||||
)
|
||||
const partialB = await waitForClientToMatchHost(
|
||||
clientB,
|
||||
remaining,
|
||||
worktreeId,
|
||||
RETRACTION_BUDGET_MS
|
||||
)
|
||||
console.error(`[two-client] phase1a host=${remaining} A=${partialA}ms B=${partialB}ms`)
|
||||
if (partialA === null || partialB === null) {
|
||||
failures.push(
|
||||
`phase1a: a client kept showing a terminal the host closed, with others still open (A=${partialA}, B=${partialB})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 1b: A empties the workspace by hand. ──
|
||||
for (const hostTabId of await readHostTerminalTabIds(clientA, worktreeId)) {
|
||||
await callEnvironment(clientA.page, clientA.environmentId, 'session.tabs.close', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: hostTabId,
|
||||
reason: 'user',
|
||||
navigation: 'caller'
|
||||
})
|
||||
}
|
||||
await expect
|
||||
.poll(() => readHostTerminalTabIds(clientA!, worktreeId).then((ids) => ids.length), {
|
||||
timeout: MIRROR_BUDGET_MS,
|
||||
message: 'host still held terminals after client A closed them all'
|
||||
})
|
||||
.toBe(0)
|
||||
// Deliberately generous: the question is whether the retraction ever arrives, not whether
|
||||
// it is prompt. A client still showing a terminal the host has destroyed is a dead pane the
|
||||
// user will click.
|
||||
const emptyA = await waitForClientToMatchHost(clientA, 0, worktreeId, RETRACTION_BUDGET_MS)
|
||||
const emptyB = await waitForClientToMatchHost(clientB, 0, worktreeId, RETRACTION_BUDGET_MS)
|
||||
const hostOwnView = await readMirroredTabCount(orcaPage, worktreeId)
|
||||
console.error(
|
||||
`[two-client] phase1b host=0 hostOwnView=${hostOwnView}` +
|
||||
` A=${emptyA}ms(${await readWorkspaceRowState(clientA.page, worktreeId)})` +
|
||||
` B=${emptyB}ms(${await readWorkspaceRowState(clientB.page, worktreeId)})`
|
||||
)
|
||||
if (emptyA === null || emptyB === null) {
|
||||
failures.push(
|
||||
`phase1b: a client kept showing terminals the host no longer has (A=${emptyA}, B=${emptyB})`
|
||||
)
|
||||
}
|
||||
|
||||
// Neither client may seed a replacement into a workspace the user deliberately emptied:
|
||||
// both hold a row for it, so both know it was emptied rather than never initialized.
|
||||
await orcaPage.waitForTimeout(10_000)
|
||||
const hostAfterSettle = (await readHostTerminalTabIds(clientA, worktreeId)).length
|
||||
console.error(`[two-client] phase1b-settled host=${hostAfterSettle}`)
|
||||
if (hostAfterSettle !== 0) {
|
||||
failures.push(
|
||||
`phase1b: the emptied workspace grew ${hostAfterSettle} terminal(s) back on its own`
|
||||
)
|
||||
}
|
||||
|
||||
// ── Phase 2: B creates a terminal again. Both clients must follow the host. ──
|
||||
await callEnvironment(clientB.page, clientB.environmentId, 'session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: true,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
const hostAfterB = (await readHostTerminalTabIds(clientB, worktreeId)).length
|
||||
const rejoinB = await waitForClientToMatchHost(
|
||||
clientB,
|
||||
hostAfterB,
|
||||
worktreeId,
|
||||
MIRROR_BUDGET_MS
|
||||
)
|
||||
const rejoinA = await waitForClientToMatchHost(
|
||||
clientA,
|
||||
hostAfterB,
|
||||
worktreeId,
|
||||
MIRROR_BUDGET_MS
|
||||
)
|
||||
console.error(`[two-client] phase2 host=${hostAfterB} A=${rejoinA}ms B=${rejoinB}ms`)
|
||||
if (rejoinA === null || rejoinB === null) {
|
||||
failures.push(
|
||||
`phase2: a client never adopted the terminal the host holds — the user sees an empty` +
|
||||
` workspace while work runs on it (host=${hostAfterB}, A=${rejoinA}, B=${rejoinB})`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await clientB?.dispose()
|
||||
await clientA?.dispose()
|
||||
}
|
||||
expect(failures, failures.join('\n')).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* The same workspace, driven by a client that starts working the moment it finishes pairing —
|
||||
* which is what a user does on a machine they have just added.
|
||||
*
|
||||
* Isolated from the two-client test above because the failure it hunts is a startup race, not a
|
||||
* multi-client one: the earlier form of that test drove the close seconds after the pairing
|
||||
* completed and repeatedly left the client's mirror stuck — sometimes still showing the terminal
|
||||
* the host had closed, sometimes stuck empty afterwards — with the link demonstrably alive.
|
||||
*/
|
||||
test('a client that works immediately after pairing stays in step with the host', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(600_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
const worktreeId = await orcaPage.evaluate(() => {
|
||||
const id = window.__store?.getState().activeWorktreeId
|
||||
if (!id) {
|
||||
throw new Error('host has no active worktree')
|
||||
}
|
||||
return id
|
||||
})
|
||||
|
||||
let client: PairedElectronClient | null = null
|
||||
const failures: string[] = []
|
||||
try {
|
||||
client = await launchPairedElectronClient(
|
||||
await createRuntimeDesktopPairingOffer(orcaPage),
|
||||
testInfo,
|
||||
'fresh-pairing-immediate-work'
|
||||
)
|
||||
await waitForClientWorkspace(client.page, worktreeId)
|
||||
await focusWorkspace(client.page, worktreeId)
|
||||
|
||||
await callEnvironment(client.page, client.environmentId, 'session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
activate: true,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
const afterCreate = (await readHostTerminalTabIds(client, worktreeId)).length
|
||||
const sawCreate = await waitForClientToMatchHost(
|
||||
client,
|
||||
afterCreate,
|
||||
worktreeId,
|
||||
MIRROR_BUDGET_MS
|
||||
)
|
||||
console.error(`[fresh-pairing] create host=${afterCreate} client=${sawCreate}ms`)
|
||||
if (sawCreate === null) {
|
||||
failures.push(
|
||||
`the client never mirrored the terminal it had just created (host=${afterCreate})`
|
||||
)
|
||||
}
|
||||
|
||||
for (const hostTabId of await readHostTerminalTabIds(client, worktreeId)) {
|
||||
await callEnvironment(client.page, client.environmentId, 'session.tabs.close', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: hostTabId,
|
||||
reason: 'user',
|
||||
navigation: 'caller'
|
||||
})
|
||||
}
|
||||
await expect
|
||||
.poll(() => readHostTerminalTabIds(client!, worktreeId).then((ids) => ids.length), {
|
||||
timeout: MIRROR_BUDGET_MS,
|
||||
message: 'host still held terminals after the client closed them all'
|
||||
})
|
||||
.toBe(0)
|
||||
const sawClose = await waitForClientToMatchHost(client, 0, worktreeId, MIRROR_BUDGET_MS)
|
||||
console.error(
|
||||
`[fresh-pairing] close client=${sawClose}ms row=${await readWorkspaceRowState(client.page, worktreeId)}`
|
||||
)
|
||||
if (sawClose === null) {
|
||||
failures.push('the client kept showing a terminal the host had already closed')
|
||||
}
|
||||
} finally {
|
||||
await client?.dispose()
|
||||
}
|
||||
expect(failures, failures.join('\n')).toEqual([])
|
||||
})
|
||||
Reference in New Issue
Block a user