diff --git a/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts new file mode 100644 index 00000000000..c83a3d1a58f --- /dev/null +++ b/tests/e2e/paired-remote-terminal-client-restart-survival.spec.ts @@ -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( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + 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 +} + +async function focusWorkspace(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) +} + +async function waitForClientWorkspace(page: Page, worktreeId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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([]) +}) diff --git a/tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts b/tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts new file mode 100644 index 00000000000..3bf73bd928f --- /dev/null +++ b/tests/e2e/paired-two-client-emptied-workspace-reseed.spec.ts @@ -0,0 +1,355 @@ +/** + * 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. + * + * 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( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + 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 +} + +/** The host's own tab inventory — the only oracle that is not a client re-derivation. */ +async function readHostTerminalTabIds( + client: PairedElectronClient, + worktreeId: string +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 1: 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] phase1 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( + `phase1: 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] phase1-settled host=${hostAfterSettle}`) + if (hostAfterSettle !== 0) { + failures.push( + `phase1: 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([]) +})