Files
orca/tests/e2e/helpers/paired-electron-client.ts
Neil eff317939a fix(terminal): mount one surface per workspace id in the workbench (STA-4846) (#17432)
* fix(terminal): mount one surface per workspace id in the workbench (STA-4846)

* test(terminal): pin the workbench projection against under-selecting

Losing a surface unmounts live terminals, which is worse than the
duplicate mount STA-4846 fixes, so cover every catalog shape that reaches
the workbench: local-only rows that name no host, an unqualified row
colliding with a host-qualified one, two SSH hosts on one id, folder rows
across three hosts, folder ids alongside git worktree ids, and a
whole-catalog assertion that the emitted id set equals the distinct input
id set. Also pin the `useAllWorktrees` -> `useWorktreeMap` swap: both read
the same WeakMap-cached snapshot, so the zustand compare is unchanged.

Harden the folder tie-break to require the row to name its own host.
`getCatalogOwnerHostId` defaults an unstamped row to `local`, which would
let a row that never named a host win the `local` tie and mount another
host's path; it now keeps first-wins instead of guessing.

* fix(terminal): surface the unresolvable folder-surface collision

When two hosts publish the same folder-workspace id and the active workspace's
host cannot be resolved, the projection drops one row's folderPath first-wins.
That path is the PTY cwd for any tab without a startupCwd, so the drop was
silent. Warn on it, and pin the two tie-break branches the unit tests missed:
a colliding row that is not the active workspace, and the same collision with
the rows in swapped order (a host reconnect re-appends its rows, flipping which
row is first mid-session).

* test(e2e): ride out Playwright's spurious main-process evaluate rejection

`e2e / changed e2e specs` failed on `pr11346-selected-runtime-add.spec.ts`
with "Execution context was destroyed, most likely because of a navigation"
from the paired client's first `app.evaluate` — the isolated-HOME assert that
runs one millisecond after `electron.launch()` resolves, which is before the
app is `ready`. Nothing navigates there: Playwright raises that message for
any main-process CDP failure that is neither a JS error nor a closed session,
and `ElectronApplication.evaluate` is unreliable on Electron 27+
(microsoft/playwright#33737). Reproduced locally, and a plain re-run of the
same commit went green.

Extract the retry `installTerminalPtyWriteSpy` already carried for this exact
message into `retryTransientMainEvaluate`, and use it for the launch-time home
read in all three launchers. The read is idempotent and a real boundary escape
still throws on the first successful read.

Also forward the paired client's process logs before the assert instead of
after: this failure reached CI with none of the client's own output, because
forwarding had not started yet.

* test(e2e): wait on the owning group before asserting a Cmd-J browser tab is active

`changed e2e specs` then failed at the remote browser-page step: the store poll
had already seen `activeBrowserTabId` land on the mirrored workspace, but
`[data-tab-id=...][data-active="true"]` never appeared. `data-active` on a
`BrowserTab` is the strip's active tab, which comes from the owning group's
`activeTabId` — not from `activeBrowserTabId` — so the DOM assert was racing an
activation the poll never waited for. The simulator rows in the same spec
already poll the group; the two browser-page rows did not.

Poll the same triple for them, so a genuinely stuck group fails with the ids it
ended on instead of a bare "element(s) not found".
2026-08-31 18:45:52 -07:00

313 lines
11 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import {
_electron as electron,
type ElectronApplication,
type Page,
type TestInfo
} from '@stablyai/playwright-test'
import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile'
import { getOrcaElectronLaunchArgs } from './electron-launch-args'
import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown'
import {
assertElectronResolvedIsolatedHome,
createElectronHomeIsolation
} from './electron-home-isolation'
import { retryTransientMainEvaluate } from './electron-main-evaluate-retry'
import { forwardElectronProcessLogs } from './orca-app'
import {
replaceRuntimePairingInPlace,
type SameIdPairingReplacement
} from './nested-runtime-same-id-pairing'
import { createPairedWebClientUrl, type PairedWebClientOptions } from './paired-web-client-url'
import { selectPairedRuntimeEnvironment } from './paired-client-runtime-environment'
export type { SameIdPairingReplacement } from './nested-runtime-same-id-pairing'
export type PairedElectronClient = {
app: ElectronApplication
page: Page
environmentId: string
captureDirectSshAttempts: () => Promise<void>
dispose: () => Promise<void>
getDirectSshAttemptTargetIds: () => Promise<string[]>
installDirectSshAttemptProbe: () => Promise<void>
replacePairingInPlace: (offer: RuntimeDesktopPairingOffer) => Promise<SameIdPairingReplacement>
/** Profile directory backing this client. Pass it to `reuseUserDataDir` to relaunch the same device. */
userDataDir: string
}
export type RuntimeDesktopPairingOffer = {
pairingUrl: string
webClientUrl?: string
}
export type PairedWebClient = {
page: Page
dispose: () => Promise<void>
}
const DIRECT_SSH_PROBE_CANARY_TARGET_ID = '__orca_e2e_direct_ssh_probe_canary__'
function readDirectSshAttemptTargetIds(probePath: string): string[] {
try {
return readFileSync(probePath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as string)
} catch {
return []
}
}
async function removeProfile(userDataDir: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
rmSync(userDataDir, { recursive: true, force: true })
return
} catch (error) {
if (attempt === 4) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
}
}
}
export async function createRuntimeDesktopPairingOffer(
hubPage: Page
): Promise<RuntimeDesktopPairingOffer> {
return hubPage.evaluate(async () => {
const offer = await window.api.mobile.getRuntimePairingUrl({
address: '127.0.0.1',
rotate: true
})
if (!offer.available || !offer.pairingUrl) {
throw new Error('HUB runtime did not provide a desktop pairing URL')
}
return {
pairingUrl: offer.pairingUrl,
...(offer.webClientUrl ? { webClientUrl: offer.webClientUrl } : {})
}
})
}
export async function launchPairedWebClient(
hubApp: ElectronApplication,
offer: RuntimeDesktopPairingOffer,
options: PairedWebClientOptions = {}
): Promise<PairedWebClient> {
if (!offer.webClientUrl) {
throw new Error('HUB runtime did not provide a paired web client URL')
}
const clientUrl = createPairedWebClientUrl(offer.webClientUrl, options)
let page: Page | undefined
const pagePromise = hubApp.waitForEvent('window').then((candidate) => (page = candidate))
try {
await hubApp.evaluate(
async ({ BrowserWindow }, { partition, url }) => {
const clientWindow = new BrowserWindow({
height: 1200,
show: false,
width: 1440,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
partition,
sandbox: true
}
})
await clientWindow.loadURL(url).catch((error) => {
clientWindow.destroy()
throw error
})
},
{
partition: `e2e-nested-runtime-web-${randomUUID()}`,
url: clientUrl
}
)
page = await pagePromise
if (options.waitForWorkspace !== false) {
await page.locator('[data-worktree-sidebar]').waitFor({ state: 'visible', timeout: 30_000 })
}
return { page, dispose: () => page?.close() ?? Promise.resolve() }
} catch (error) {
void pagePromise.catch(() => undefined)
await page?.close().catch(() => undefined)
throw error
}
}
export async function launchPairedElectronClient(
offer: RuntimeDesktopPairingOffer,
testInfo: TestInfo,
name: string,
// reuseUserDataDir relaunches on an existing profile, so its stored pairing credential — and
// therefore its pairedDeviceId — survives the restart, as it does for a real force-quit reopen.
options: { extraEnv?: Record<string, string>; reuseUserDataDir?: string } = {}
): Promise<PairedElectronClient> {
const reusedProfile = options.reuseUserDataDir !== undefined
const userDataDir =
options.reuseUserDataDir ?? mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-paired-desktop-'))
const directSshProbePath = path.join(userDataDir, 'forbidden-local-ssh-connects.jsonl')
if (!reusedProfile) {
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
)
}
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
const homeIsolation = createElectronHomeIsolation({
inheritedEnv: cleanEnv,
launchEnv: {},
extraEnv: options.extraEnv ?? {},
userDataDir
})
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
const app = await electron.launch({
args: getOrcaElectronLaunchArgs(mainPath, false),
env: {
...homeIsolation.env,
NODE_ENV: 'development',
ORCA_E2E_HEADLESS: '1',
ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE: directSshProbePath
}
})
// Why before the home assert: forwarding starts here, so a client that fails during startup
// otherwise reaches CI as a bare Playwright error with none of its own output attached.
forwardElectronProcessLogs(app, testInfo)
try {
assertElectronResolvedIsolatedHome(
await retryTransientMainEvaluate(() =>
app.evaluate(({ app: electronApp }) => electronApp.getPath('home'))
),
homeIsolation
)
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 30_000 }
)
const canaryBlocked = await page.evaluate(async (targetId) => {
try {
await window.api.ssh.connect({ targetId })
return false
} catch (error) {
return String(error).includes('e2e_forbidden_local_ssh_connect')
}
}, DIRECT_SSH_PROBE_CANARY_TARGET_ID)
if (
!canaryBlocked ||
!readDirectSshAttemptTargetIds(directSshProbePath).includes(DIRECT_SSH_PROBE_CANARY_TARGET_ID)
) {
throw new Error('Paired-client direct SSH probe did not intercept its canary attempt')
}
const environmentId = await selectPairedRuntimeEnvironment(page, {
name,
pairingUrl: offer.pairingUrl,
reusedProfile
})
const captureDirectSshAttempts = async (): Promise<void> => {}
const replacePairingInPlace = async (
replacementOffer: RuntimeDesktopPairingOffer
): Promise<SameIdPairingReplacement> =>
replaceRuntimePairingInPlace({
environmentId,
page,
pairingUrl: replacementOffer.pairingUrl,
userDataDir
})
return {
app,
page,
environmentId,
captureDirectSshAttempts,
dispose: async () => {
await closeElectronAppForE2E(app)
await cleanupE2EDaemons(userDataDir)
await removeProfile(userDataDir)
},
getDirectSshAttemptTargetIds: async () => {
return readDirectSshAttemptTargetIds(directSshProbePath).filter(
(targetId) => targetId !== DIRECT_SSH_PROBE_CANARY_TARGET_ID
)
},
installDirectSshAttemptProbe: async () => {},
replacePairingInPlace,
userDataDir
}
} catch (error) {
await closeElectronAppForE2E(app)
await cleanupE2EDaemons(userDataDir)
await removeProfile(userDataDir)
throw error
}
}
export async function rePairPairedElectronClient(
client: PairedElectronClient,
offer: RuntimeDesktopPairingOffer,
name: string
): Promise<void> {
await client.captureDirectSshAttempts()
const environmentId = await client.page.evaluate(
async ({ currentEnvironmentId, name, pairingUrl }) => {
const store = window.__store
if (!store) {
throw new Error('Paired desktop store is unavailable')
}
await window.api.runtimeEnvironments.remove({ selector: currentEnvironmentId })
const result = await window.api.runtimeEnvironments.addFromPairingCode({
name,
pairingCode: pairingUrl
})
store.getState().setRuntimeEnvironments(await window.api.runtimeEnvironments.list())
if (!(await store.getState().refreshRuntimeEnvironmentStatus(result.environment.id))) {
throw new Error('Re-paired desktop could not reach the HUB runtime')
}
if (!(await store.getState().setActiveRuntimeEnvironmentPreference(result.environment.id))) {
throw new Error('Re-paired desktop could not select the HUB runtime')
}
return result.environment.id
},
{
currentEnvironmentId: client.environmentId,
name,
pairingUrl: offer.pairingUrl
}
)
client.environmentId = environmentId
// Why: removing and re-adding the same HUB changes the environment identity; remount so no pane keeps the retired transport wrapper.
await client.page.reload()
await client.page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 30_000 }
)
await client.installDirectSshAttemptProbe()
const reachable = await client.page.evaluate(async (nextEnvironmentId) => {
const store = window.__store
if (!store) {
throw new Error('Re-paired desktop store is unavailable after reload')
}
if (!(await store.getState().refreshRuntimeEnvironmentStatus(nextEnvironmentId))) {
return false
}
return store.getState().setActiveRuntimeEnvironmentPreference(nextEnvironmentId)
}, environmentId)
if (!reachable) {
throw new Error('Re-paired desktop could not reach the HUB after reload')
}
}