Files
orca/tests/e2e/helpers/orca-restart.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

354 lines
12 KiB
TypeScript

/**
* Two-launch Electron helper for restart-persistence tests.
*
* Why: the default `orcaPage` fixture creates a fresh `userDataDir` per test
* and deletes it on close, which is incompatible with a test that needs to
* quit the app and relaunch against the *same* on-disk state. This helper
* owns the shared userDataDir and gives each caller an `app`+`page` pair
* wired to it.
*/
import {
_electron as electron,
expect,
type ElectronApplication,
type Page,
type TestInfo
} from '@stablyai/playwright-test'
import { execSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile'
import { getOrcaElectronLaunchArgs } from './electron-launch-args'
import { retryTransientMainEvaluate } from './electron-main-evaluate-retry'
import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown'
import {
assertElectronResolvedIsolatedHome,
createElectronHomeIsolation,
type ElectronHomeIsolation
} from './electron-home-isolation'
type LaunchedOrca = {
app: ElectronApplication
page: Page
}
type LaunchOptions = {
/**
* Called for each chunk the relaunched main process writes to stderr. The
* listener is attached before `firstWindow()` resolves so main-process
* startup logs (e.g. the daemon health-check guard) can't be emitted before
* the test starts capturing.
*/
onStderr?: (chunk: string) => void
/** Merged into this launch only (not baked into the session's shared env). */
extraEnv?: Record<string, string>
}
type RestartSession = {
userDataDir: string
seedCodexResumeRollout: (sessionId: string, cwd: string) => string
launch: (options?: LaunchOptions) => Promise<LaunchedOrca>
/** Gracefully close a launch, letting beforeunload flush session state. */
close: (app: ElectronApplication) => Promise<void>
/** Remove the shared userDataDir after the test is done. */
dispose: () => Promise<void>
}
async function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
const timeout = setTimeout(resolve, ms)
timeout.unref?.()
})
}
async function reserveRestartRuntimeWsPort(): Promise<number> {
const server = createServer()
return new Promise<number>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close()
reject(new Error('Restart fixture could not reserve a runtime WebSocket port'))
return
}
server.close((error) => (error ? reject(error) : resolve(address.port)))
})
})
}
async function removeProfileDir(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
}
// Why: on Windows, taskkill can return before Electron/PTY handles are
// fully released, making immediate temp-profile deletion flaky.
await delay(250)
}
}
}
function shouldLaunchHeadful(testInfo: TestInfo): boolean {
return testInfo.project.metadata.orcaHeadful === true
}
function createRestartLaunchIsolation(
userDataDir: string,
headful: boolean,
extraEnv: Record<string, string>
): ElectronHomeIsolation {
const { ELECTRON_RUN_AS_NODE: _unused, ...cleanEnv } = process.env
void _unused
return createElectronHomeIsolation({
inheritedEnv: cleanEnv,
launchEnv: {
NODE_ENV: 'development',
...((process.env.ORCA_E2E_SSH_LOCALHOST === '1' ||
process.env.ORCA_E2E_SSH_DOCKER === '1' ||
process.env.ORCA_E2E_NESTED_RUNTIME_SSH === '1') &&
!cleanEnv.ORCA_RELAY_PATH
? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') }
: {}),
...extraEnv,
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
},
extraEnv: {},
userDataDir
})
}
/**
* Create a restart session tied to a persistent userDataDir.
*
* Why: keep the launch wiring identical to the shared fixture (mainPath,
* env stripping, headful toggle) so behavior differences between fixtures
* don't leak in as false positives for persistence bugs.
*/
export function createRestartSession(
testInfo: TestInfo,
extraEnv: Record<string, string> = {}
): RestartSession {
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-restart-'))
const headful = shouldLaunchHeadful(testInfo)
const homeIsolation = createRestartLaunchIsolation(userDataDir, headful, extraEnv)
let runtimeWsPort: number | null = null
// Why: this helper bypasses the shared `electronApp` fixture, so it must
// seed the same completed onboarding profile or first-run overlays cover
// both launches and obscure restart failures.
writeFileSync(
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(getE2ECompletedOnboardingProfile(), null, 2)}\n`
)
const seedCodexResumeRollout = (sessionId: string, cwd: string): string => {
const sessionsDir = path.join(
homeIsolation.isolatedHome,
'.codex',
'sessions',
'2026',
'07',
'28'
)
mkdirSync(sessionsDir, { recursive: true })
const transcriptPath = path.join(sessionsDir, `rollout-2026-07-28T00-00-00-${sessionId}.jsonl`)
writeFileSync(
transcriptPath,
`${JSON.stringify({
timestamp: '2026-07-28T00:00:00.000Z',
type: 'session_meta',
payload: { id: sessionId, cwd }
})}\n`
)
return transcriptPath
}
const launch = async (options?: LaunchOptions): Promise<LaunchedOrca> => {
runtimeWsPort ??= await reserveRestartRuntimeWsPort()
const app = await electron.launch({
args: getOrcaElectronLaunchArgs(mainPath, headful),
env: {
...homeIsolation.env,
...options?.extraEnv,
ORCA_E2E_RUNTIME_WS_PORT: String(runtimeWsPort)
}
})
// Why: attach before firstWindow — the main-process daemon guard and the
// plugin-system startup metrics can both emit before the renderer is ready.
if (options?.onStderr) {
const onStderr = options.onStderr
app.process().stderr?.on('data', (chunk: Buffer) => onStderr(chunk.toString()))
}
try {
const resolvedHome = await retryTransientMainEvaluate(() =>
app.evaluate(({ app }) => app.getPath('home'))
)
assertElectronResolvedIsolatedHome(resolvedHome, homeIsolation)
} catch (error) {
await closeElectronAppForE2E(app)
throw error
}
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
return { app, page }
}
const close = async (app: ElectronApplication): Promise<void> => {
await closeElectronAppForE2E(app)
}
const dispose = async (): Promise<void> => {
await cleanupE2EDaemons(userDataDir)
if (process.env.ORCA_E2E_PRESERVE_RESTART_PROFILE === '1') {
console.log(`[e2e] Preserved restart profile at ${userDataDir}`)
return
}
if (existsSync(userDataDir)) {
await removeProfileDir(userDataDir)
}
}
return { userDataDir, seedCodexResumeRollout, launch, close, dispose }
}
/**
* Attach a repo to the running renderer and wait until a terminal tab is
* active on its worktree. Matches the shared fixture's setup path so the
* first-launch state lines up with what real users persist.
*/
export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): Promise<string> {
if (!isValidGitRepo(repoPath)) {
throw new Error(`attachRepoAndOpenTerminal: ${repoPath} is not a git repo`)
}
const repoId = await page.evaluate(async (repoPath) => {
const result = await window.api.repos.add({ path: repoPath })
if ('error' in result) {
throw new Error(result.error)
}
return result.repo.id
}, repoPath)
await expect
.poll(
() =>
readRestartRendererState(() =>
page.evaluate(async (repoId) => {
const store = window.__store
if (!store) {
return false
}
// Why: repos.add emits a concurrent refresh whose generation can
// supersede this fetch; poll until either refresh publishes the repo.
await store.getState().fetchRepos()
const repo = store.getState().repos.find((candidate) => candidate.id === repoId)
if (!repo) {
return false
}
// Why: this restart fixture uses the global e2e repo, whose seeded Git
// worktree is external to Orca's workspace root after the visibility rollout.
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
return true
}, repoId)
),
{
timeout: 30_000,
message: `attachRepoAndOpenTerminal: expected e2e repo to be loaded: ${repoPath}`
}
)
.toBe(true)
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 30_000 }
)
// Why: fetchWorktrees() is async. Awaiting the outer page.evaluate returns
// before the Zustand worktree slice has observed the hydrated state, so a
// single evaluate() that reads worktreesByRepo can see an empty map. Poll
// the store until the seeded repo's worktree shows up.
await expect
.poll(
async () =>
readRestartRendererState(() =>
page.evaluate(async (repoId) => {
const store = window.__store
if (!store) {
return false
}
await store.getState().fetchWorktrees(repoId)
return (store.getState().worktreesByRepo[repoId]?.length ?? 0) > 0
}, repoId)
),
{
timeout: 15_000,
message: 'attachRepoAndOpenTerminal: seeded worktree never surfaced in the store'
}
)
.toBe(true)
const worktreeId = await page.evaluate((repoId: string) => {
const store = window.__store
if (!store) {
return null
}
const state = store.getState()
// Why: repo identity remains stable when Windows canonicalizes path casing
// or separators between the IPC and renderer layers.
const repoWorktrees = state.worktreesByRepo[repoId] ?? []
const primary = repoWorktrees.find((worktree) => worktree.isMainWorktree) ?? repoWorktrees[0]
if (!primary) {
return null
}
state.setActiveWorktree(primary.id)
return primary.id
}, repoId)
if (!worktreeId) {
throw new Error('attachRepoAndOpenTerminal: test repo did not surface in the store')
}
return worktreeId
}
export async function readRestartRendererState<T>(read: () => Promise<T>): Promise<T | null> {
try {
return await read()
} catch (error) {
// Why: initial hydration can replace the renderer document; the enclosing
// state poll must retry that transition without hiding other failures.
if (error instanceof Error && error.message.includes('Execution context was destroyed')) {
return null
}
throw error
}
}
function isValidGitRepo(repoPath: string): boolean {
if (!repoPath || !existsSync(repoPath)) {
return false
}
try {
return (
execSync('git rev-parse --is-inside-work-tree', {
cwd: repoPath,
stdio: 'pipe',
encoding: 'utf8'
}).trim() === 'true'
)
} catch {
return false
}
}