diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 22f167feec70..8770700b8186 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -534,11 +534,14 @@ jobs: name: e2e-build-out path: out/ # The packaged orcad slot the T6-11 launcher runs: its own node-pty under the pinned Node. - - name: Build this runner's orcad slot + # The template is what `orca serve`'s default selection materializes that slot from. + - name: Build this runner's orcad slot and template run: | + slot="$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" pnpm build:orcad-prebuilds - pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + pnpm build:orcad-prebuilds --require-slots "$slot" pnpm build:orcad + node config/scripts/build-orcad-template.mjs --targets "$slot" - name: Switch serve hosts on one profile env: SKIP_BUILD: '1' @@ -641,6 +644,56 @@ jobs: retention-days: 7 if-no-files-found: ignore + # D7 on Windows: both hosts share \daemon and so one daemon pipe. Built here rather than + # from the Linux e2e build because the slot, template and addons are Windows-native. + orcad-serve-mode-switch-windows: + name: orca serve Electron/orcad mode switch on Windows (D7) + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') + runs-on: windows-2022 + timeout-minutes: 45 + env: + NODE_OPTIONS: --max-old-space-size=4096 + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - uses: ./.github/actions/prepare-orcad-prebuilds + with: + resolve-windows-cache: 'true' + restore-windows-cache: 'true' + - name: Build the e2e app, CLI, orcad slot and template + shell: bash + run: | + pnpm run build:relay + pnpm exec electron-vite build --mode e2e + pnpm run build:cli + pnpm build:orcad + node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64 + node config/scripts/build-orcad-template.mjs --targets win32-x64 + - name: Switch serve hosts on one profile + env: + SKIP_BUILD: '1' + ORCA_E2E_ORCAD_SERVE: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + ORCA_E2E_PRESERVE_PROFILE_LOGS_DIR: ${{ github.workspace }}\test-results\profile-logs + # A launch that dies before its window prints this trail into Playwright's call log. + ORCA_STARTUP_DIAGNOSTICS: '1' + ELECTRON_ENABLE_LOGGING: '1' + ELECTRON_ENABLE_STACK_DUMPING: '1' + run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1 + - uses: actions/upload-artifact@v7 + if: failure() + with: + name: orcad-serve-mode-switch-windows-traces + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + ssh-localhost: name: localhost SSH terminal and hooks needs: [build, prepare-native-cache] diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 41d24c100af1..522b26efe937 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -13,6 +13,7 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#e2e': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-auto-convert-docker': { contents: 'read' }, '.github/workflows/e2e.yml#orcad-serve-mode-switch': { contents: 'read' }, + '.github/workflows/e2e.yml#orcad-serve-mode-switch-windows': { contents: 'read' }, '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-browser-network-route': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-localhost': { contents: 'read' }, diff --git a/docs/reference/orcad-operations.md b/docs/reference/orcad-operations.md index c6c9c6fcfeea..4232c24f0ec0 100644 --- a/docs/reference/orcad-operations.md +++ b/docs/reference/orcad-operations.md @@ -66,6 +66,24 @@ the daemon shares the service cgroup and a combined-unit stop ends live terminal it is populated from `/proc/self/cgroup`, so it reports the isolation the daemon actually has rather than what the launcher intended. +## `orca serve` on this machine + +`orca serve` runs on the local orcad slot by default. The CLI asks the app's +`out/main/orcad/orcad-local-serve-selection-entry.js` (run as plain Node on the app's executable) +which host to use. Any reason orcad cannot serve falls back to Electron serve with one +`[serve] using Electron serve: ` line on stderr. Those reasons are: no slot for this host, +no template in the install, the pinned Node could not be fetched, or a failed native preflight. + +- `ORCA_SERVE_RUNTIME=electron` keeps Electron serve and skips the question. `orcad` (or unset) is + the default, and any other value falls back with a reason. +- Packaged macOS stays on Electron: only Electron serve, supervised by the CLI, can take a remote + app update there, and orcad has no updater. Recipe-JSON serve has no handoff and uses orcad. +- Windows defaults to Electron serve for now; `ORCA_SERVE_RUNTIME=orcad` opts in. Both hosts share + `\daemon` and so one daemon pipe. The `orcad-serve-mode-switch-windows` e2e job gates D7 + on Windows. + +The slot and its pinned Node live under the desktop's `/orcad-artifacts`. + ## Bind policy `--bind `, **default `127.0.0.1`**. diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index f23e3d94a6af..3e674b66cdb9 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -167,6 +167,8 @@ orca-ide serve --port 6768 --pairing-address 100.64.1.20 Use only one host mode at a time. If the Orca desktop app is already sharing that computer, do not start a second `orca serve` process for the same setup. +`orca serve` runs on Orca's own Node server (orcad) by default. If orcad can't serve on this computer, it uses the desktop app's server instead and prints one line saying why. On Windows it uses the desktop app's server unless you set `ORCA_SERVE_RUNTIME=orcad`, and on a packaged macOS app it currently always does, so paired clients can still update it. To always use the desktop app's server, set `ORCA_SERVE_RUNTIME=electron`. + ### Mobile from a headless server For the Orca mobile app, request a mobile-scoped QR code and link: diff --git a/src/cli/runtime/launch-serve-runtime.test.ts b/src/cli/runtime/launch-serve-runtime.test.ts new file mode 100644 index 000000000000..6ff70ff98106 --- /dev/null +++ b/src/cli/runtime/launch-serve-runtime.test.ts @@ -0,0 +1,93 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, resolveLocalServeRuntimeMock, serveWithOrcadMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + resolveLocalServeRuntimeMock: vi.fn(), + serveWithOrcadMock: vi.fn() +})) + +vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn() })) +vi.mock('./serve-orcad-launch', () => ({ + resolveLocalServeRuntime: resolveLocalServeRuntimeMock, + serveWithOrcad: serveWithOrcadMock +})) + +import { serveOrcaApp } from './launch' + +class FakeChildProcess extends EventEmitter { + stdout = new EventEmitter() + kill = vi.fn() + unref = vi.fn() + pid = 4101 +} + +/** Electron serve that exits cleanly once spawned, whenever selection gets to spawning it. */ +function electronChild(): void { + spawnMock.mockImplementation(() => { + const child = new FakeChildProcess() + setTimeout(() => child.emit('exit', 0, null), 0) + return child + }) +} + +describe('orca serve host selection', () => { + let stderr: string[] + + beforeEach(() => { + spawnMock.mockReset() + resolveLocalServeRuntimeMock.mockReset() + serveWithOrcadMock.mockReset() + process.env.ORCA_APP_EXECUTABLE = '/opt/orca/orca-ide' + stderr = [] + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)) + return true + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + delete process.env.ORCA_APP_EXECUTABLE + delete process.env.ORCA_SERVE_RUNTIME + }) + + it('serves on orcad by default', async () => { + const selection = { kind: 'orcad', runtime: '/node', entry: '/slot/orcad.js', version: '1' } + resolveLocalServeRuntimeMock.mockResolvedValue(selection) + serveWithOrcadMock.mockResolvedValue(0) + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(serveWithOrcadMock).toHaveBeenCalledWith( + selection, + { json: true }, + expect.any(String), + expect.any(Function) + ) + expect(spawnMock).not.toHaveBeenCalled() + expect(stderr.join('')).toContain('[serve] running on orcad 1') + }) + + it('falls back to Electron and prints why when orcad cannot serve', async () => { + resolveLocalServeRuntimeMock.mockResolvedValue({ kind: 'electron', reason: 'no template' }) + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(spawnMock).toHaveBeenCalledWith( + '/opt/orca/orca-ide', + expect.arrayContaining(['--serve', '--serve-json']), + expect.any(Object) + ) + expect(stderr.join('')).toContain('[serve] using Electron serve: no template') + }) + + it('keeps Electron without asking orcad when ORCA_SERVE_RUNTIME=electron', async () => { + process.env.ORCA_SERVE_RUNTIME = 'electron' + electronChild() + + await expect(serveOrcaApp({ json: true })).resolves.toBe(0) + expect(resolveLocalServeRuntimeMock).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledOnce() + expect(stderr.join('')).not.toContain('[serve]') + }) +}) diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts index 7931e489e3da..584ab18e503d 100644 --- a/src/cli/runtime/launch.test.ts +++ b/src/cli/runtime/launch.test.ts @@ -90,10 +90,13 @@ describe('serveOrcaApp', () => { spawnMock.mockReset() spawnSyncMock.mockReset() process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca' + // These cover Electron serve itself; the orcad default is in launch-serve-runtime.test.ts. + process.env.ORCA_SERVE_RUNTIME = 'electron' }) afterEach(() => { vi.restoreAllMocks() + delete process.env.ORCA_SERVE_RUNTIME delete process.env.ORCA_APP_EXECUTABLE delete process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT delete process.env.ORCA_USER_DATA_PATH diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index 0df2014ecb7c..e0d8f5cfecbb 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -14,7 +14,7 @@ import { superviseForegroundServe } from './serve-update-supervisor' import { RuntimeClientError } from './types' -import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' +import { SERVE_RUNTIME_ELECTRON, SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection' import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch' import { waitForRecipeJson } from './serve-recipe-json' @@ -86,8 +86,8 @@ export type ServeOrcaAppArgs = { export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise { const executable = resolveForegroundOrcaExecutable() - // Why synchronous unless opted in: the default path must spawn Electron exactly as before. - if (process.env[SERVE_RUNTIME_ENV] !== 'orcad') { + // Why synchronous on the opt-out: it must spawn Electron exactly as before, without asking. + if (process.env[SERVE_RUNTIME_ENV] === SERVE_RUNTIME_ELECTRON) { return serveWithElectron(executable, args) } if (args.recipeJson && !args.projectRoot) { diff --git a/src/main/orcad/orcad-daemon-session-client-fixture.ts b/src/main/orcad/orcad-daemon-session-client-fixture.ts index a421ddd00aa6..b9e058a091f9 100644 --- a/src/main/orcad/orcad-daemon-session-client-fixture.ts +++ b/src/main/orcad/orcad-daemon-session-client-fixture.ts @@ -25,10 +25,13 @@ export async function buildDaemonSessionClient(outfile: string): Promise { adapter.onData(event => { if (event.id === sessionId) output += event.data }) const deadline = setTimeout(() => { console.error('timed out; output: ' + output); process.exit(98) }, 20_000) ;(async () => { + const win32 = process.platform === 'win32' const spawned = await adapter.spawn(op === 'create' - ? { sessionId, cols: 80, rows: 24, cwd, shellOverride: '/bin/sh' } + ? { sessionId, cols: 80, rows: 24, cwd, shellOverride: win32 ? 'cmd.exe' : '/bin/sh' } : { sessionId, cols: 80, rows: 24 }) - adapter.write(spawned.id, "printf 'ORCA_SERVE_%s\\\\n' " + marker + "\\r") + // Both shells echo what is typed, so the typed form must not already read as the marker. + const typed = win32 ? 'echo ORCA_^SERVE_' + marker : "printf 'ORCA_SERVE_%s\\\\n' " + marker + adapter.write(spawned.id, typed + "\\r") while (!output.includes('ORCA_SERVE_' + marker)) await new Promise(r => setTimeout(r, 50)) clearTimeout(deadline) await adapter.disconnectOnly() diff --git a/src/main/orcad/orcad-local-serve-selection.test.ts b/src/main/orcad/orcad-local-serve-selection.test.ts index 4a90bc1455fe..52bf01962343 100644 --- a/src/main/orcad/orcad-local-serve-selection.test.ts +++ b/src/main/orcad/orcad-local-serve-selection.test.ts @@ -42,7 +42,7 @@ function input(overrides: Partial = {}): ServeRuntim const cachedNode = join(root, 'cached-node') writeFileSync(cachedNode, '#!/bin/sh\n') return { - env: { [SERVE_RUNTIME_ENV]: 'orcad' }, + env: {}, platform: 'linux', userDataPath: root, templateDirs: [join(root, 'missing-template'), template], @@ -56,11 +56,21 @@ function input(overrides: Partial = {}): ServeRuntim } describe('orca serve runtime selection', () => { - it('stays on Electron, silently, unless orcad is asked for', async () => { - expect(await selectServeRuntime(input({ env: {} }))).toEqual({ - kind: 'electron', - reason: null - }) + it('stays on Electron, silently, when Electron is asked for', async () => { + const options = input({ env: { [SERVE_RUNTIME_ENV]: 'electron' } }) + expect(await selectServeRuntime(options)).toEqual({ kind: 'electron', reason: null }) + expect(options.materializeSlot).not.toHaveBeenCalled() + }) + + it('serves on orcad by default and when orcad is asked for by name', async () => { + for (const env of [{}, { [SERVE_RUNTIME_ENV]: 'orcad' }]) { + expect(await selectServeRuntime(input({ env }))).toMatchObject({ kind: 'orcad' }) + } + }) + + it('serves on orcad on Windows only when orcad is asked for by name', async () => { + const options = input({ env: { [SERVE_RUNTIME_ENV]: 'orcad' }, platform: 'win32' }) + expect(await selectServeRuntime(options)).toMatchObject({ kind: 'orcad' }) }) it('runs the local slot on its pinned Node, linked into userData beside it', async () => { @@ -83,8 +93,13 @@ describe('orca serve runtime selection', () => { }) it.each([ - ['Windows', { platform: 'win32' as const }, 'local orcad on Windows is not enabled'], - ['packaged macOS', { usesMacUpdateHandoff: true }, 'no macOS app-update handoff'], + [ + 'an unknown runtime name', + { env: { [SERVE_RUNTIME_ENV]: 'bun' } }, + 'ORCA_SERVE_RUNTIME=bun is neither orcad nor electron' + ], + ['Windows by default', { platform: 'win32' as const }, 'not the default on Windows yet'], + ['packaged macOS', { usesMacUpdateHandoff: true }, 'so paired clients can still update it'], ['an unsupported host', { hostTarget: () => 'linux-riscv64-glibc' }, 'no orcad build exists'], ['an install without the template', { templateDirs: [] }, 'carries no orcad template'], [ diff --git a/src/main/orcad/orcad-local-serve-selection.ts b/src/main/orcad/orcad-local-serve-selection.ts index ec36a29c0a8a..5c3731d4192d 100644 --- a/src/main/orcad/orcad-local-serve-selection.ts +++ b/src/main/orcad/orcad-local-serve-selection.ts @@ -3,7 +3,7 @@ * App-side on purpose: the CLI runs it through `orcad-local-serve-selection-entry.ts`, so the * CLI bundle never carries the materializers. * - * Opt-in while orcad serve is new: `ORCA_SERVE_RUNTIME=orcad`. Anything that stops orcad from + * orcad by default; `ORCA_SERVE_RUNTIME=electron` opts out. Anything that stops orcad from * serving this machine (no slot for the target, no pinned Node, a native module it cannot load, * a host or packaging path it does not cover yet) falls back to Electron with one stderr line * saying why. Everything this writes stays inside the desktop's userData (design D7): the slot @@ -19,6 +19,7 @@ import { parseOrcadNativePreflightReport } from '../../shared/orcad-native-preflight-report' import { + SERVE_RUNTIME_ELECTRON, SERVE_RUNTIME_ENV, type ServeRuntimeSelection } from '../../shared/orcad-local-serve-selection' @@ -53,15 +54,24 @@ function electron(reason: string): ServeRuntimeSelection { export async function selectServeRuntime( input: ServeRuntimeSelectionInput ): Promise { - if (input.env[SERVE_RUNTIME_ENV] !== 'orcad') { + const requested = input.env[SERVE_RUNTIME_ENV] + if (requested === SERVE_RUNTIME_ELECTRON) { return { kind: 'electron', reason: null } } - if (input.platform === 'win32') { - return electron('local orcad on Windows is not enabled in this build') + if (requested && requested !== 'orcad') { + return electron(`${SERVE_RUNTIME_ENV}=${requested} is neither orcad nor electron`) } + // Why: Windows stays on Electron by default until orcad serve is flipped on there separately. + if (!requested && input.platform === 'win32') { + return electron( + 'local orcad serve is not the default on Windows yet; set ORCA_SERVE_RUNTIME=orcad to opt in' + ) + } + // Why: only packaged macOS serve can take a remote app update, through Electron's updater + // and this CLI's supervisor; orcad has no updater, so switching would drop that. if (input.usesMacUpdateHandoff) { return electron( - 'orcad has no macOS app-update handoff yet; packaged macOS serve stays on Electron' + 'packaged macOS serve stays on Electron so paired clients can still update it (orcad has no app updater)' ) } const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))() diff --git a/src/main/orcad/orcad-serve-parity.integration.test.ts b/src/main/orcad/orcad-serve-parity.integration.test.ts index 418c3dd63c64..99ac6746dfa5 100644 --- a/src/main/orcad/orcad-serve-parity.integration.test.ts +++ b/src/main/orcad/orcad-serve-parity.integration.test.ts @@ -142,47 +142,45 @@ describe.skipIf(skip)('orca serve on orcad', () => { expect(result.stderr).toContain('The Orca desktop app') }, 90_000) - // POSIX: the client spawns /bin/sh, and a short /tmp root keeps the daemon socket path legal. - it.runIf(process.platform !== 'win32')( - 'keeps a live terminal across a serve restart on the shared profile (D7)', - async () => { - const userData = realpathSync(mkdtempSync('/tmp/orca-serve-d7-')) - roots.push(userData) - const client = join(userData, 'daemon-client.cjs') - await buildDaemonSessionClient(client) - const session = async (op: 'create' | 'attach', marker: string) => { - const result = await runProcess({ - program: runtime!, - args: [client, op, join(userData, 'daemon'), 'serve-d7', marker, userData], - env: serveEnv(userData), - timeoutMs: 30_000 - }) - expect(result.code, result.stderr).toBe(0) - return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') - } - const first = await serveOnce(userData) - let daemonPid: number | null = first - try { - const created = await session('create', 'BEFORE') - expect(created).toMatchObject({ isReattach: false, output: true }) - await stopServe(userData) - // The same daemon, in the same \`/daemon\` Electron serve uses, is adopted. - expect(await serveOnce(userData)).toBe(first) - expect(await session('attach', 'AFTER')).toEqual({ - pid: created.pid, - isReattach: true, - output: true - }) - } finally { - await stopServe(userData) - if (daemonPid) { - await killAndAwaitExit([daemonPid]) - daemonPid = null - } + // A short /tmp root keeps the POSIX daemon socket path legal; Windows uses a named pipe. + it('keeps a live terminal across a serve restart on the shared profile (D7)', async () => { + const userData = realpathSync( + mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'orca-serve-d7-')) + ) + roots.push(userData) + const client = join(userData, 'daemon-client.cjs') + await buildDaemonSessionClient(client) + const session = async (op: 'create' | 'attach', marker: string) => { + const result = await runProcess({ + program: runtime!, + args: [client, op, join(userData, 'daemon'), 'serve-d7', marker, userData], + env: serveEnv(userData), + timeoutMs: 30_000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') + } + const first = await serveOnce(userData) + let daemonPid: number | null = first + try { + const created = await session('create', 'BEFORE') + expect(created).toMatchObject({ isReattach: false, output: true }) + await stopServe(userData) + // The same daemon, in the same \`/daemon\` Electron serve uses, is adopted. + expect(await serveOnce(userData)).toBe(first) + expect(await session('attach', 'AFTER')).toEqual({ + pid: created.pid, + isReattach: true, + output: true + }) + } finally { + await stopServe(userData) + if (daemonPid) { + await killAndAwaitExit([daemonPid]) + daemonPid = null } - }, - 300_000 - ) + } + }, 300_000) }) const running = new Map>() diff --git a/src/shared/orcad-local-serve-selection.ts b/src/shared/orcad-local-serve-selection.ts index f27d61fafd35..5a431f42d9ab 100644 --- a/src/shared/orcad-local-serve-selection.ts +++ b/src/shared/orcad-local-serve-selection.ts @@ -4,6 +4,8 @@ * `out/main/.js` under ELECTRON_RUN_AS_NODE and reads one line. */ export const SERVE_RUNTIME_ENV = 'ORCA_SERVE_RUNTIME' +/** `ORCA_SERVE_RUNTIME=electron` keeps `orca serve` on Electron; orcad is the default. */ +export const SERVE_RUNTIME_ELECTRON = 'electron' export const ORCAD_LOCAL_SERVE_SELECTION_ENTRY = 'orcad/orcad-local-serve-selection-entry' export const ORCAD_LOCAL_SERVE_SELECTION_FLAGS = { userData: '--user-data', @@ -14,7 +16,7 @@ const RESULT_MARKER = 'ORCA_SERVE_RUNTIME' export type ServeRuntimeSelection = | { kind: 'orcad'; runtime: string; entry: string; version: string } - /** `reason` is null when orcad was not asked for, so nothing is printed. */ + /** `reason` is null when Electron was asked for explicitly, so nothing is printed. */ | { kind: 'electron'; reason: string | null } export function formatServeRuntimeSelection(selection: ServeRuntimeSelection): string { diff --git a/tests/e2e/helpers/electron-home-isolation.ts b/tests/e2e/helpers/electron-home-isolation.ts index 0c800e2bb9a4..2d3db0098aed 100644 --- a/tests/e2e/helpers/electron-home-isolation.ts +++ b/tests/e2e/helpers/electron-home-isolation.ts @@ -76,6 +76,12 @@ export function createElectronHomeIsolation({ // short names). Git canonicalizes worktree paths, so a non-canonical HOME // makes freshly created worktrees invisible to Orca's listing comparisons. const isolatedHome = realpathSync.native(requestedIsolatedHome) + // Why: Windows resolves roaming AppData under USERPROFILE, and Electron 43 crashes natively + // (0xFFFF7003) when that lookup fails before userData is pinned. + if (process.platform === 'win32') { + mkdirSync(path.join(isolatedHome, 'AppData', 'Roaming'), { recursive: true }) + mkdirSync(path.join(isolatedHome, 'AppData', 'Local'), { recursive: true }) + } // Why: a bad fixture path must fail before Electron can resolve a real Codex // home; userData isolation alone does not change app.getPath('home'). if (areSameHomePath(isolatedHome, realHome)) { diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 560a0a5dfd52..bdf84015ebdd 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { RmOptions } from 'node:fs' import { createServer } from 'node:net' import type { AddressInfo } from 'node:net' import os from 'node:os' @@ -44,6 +45,18 @@ export type HeadlessPairedRuntimeHost = { type HeadlessHostCleanup = () => Promise | void +// Why: Windows keeps a just-killed daemon's handles on the profile briefly, so removal sees EPERM. +const PROFILE_REMOVAL: RmOptions = { recursive: true, force: true, maxRetries: 50, retryDelay: 100 } + +/** CI diagnostics: the profile's logs outlive its deletion when this names a directory. */ +function preserveProfileLogs(userDataDir: string): void { + const target = process.env.ORCA_E2E_PRESERVE_PROFILE_LOGS_DIR + const logs = path.join(userDataDir, 'logs') + if (target && existsSync(logs)) { + cpSync(logs, path.join(target, path.basename(userDataDir)), { recursive: true }) + } +} + async function cleanupHeadlessHostResources(cleanups: HeadlessHostCleanup[]): Promise { const failures: unknown[] = [] for (const cleanup of cleanups) { @@ -140,6 +153,9 @@ export async function launchHeadlessPairedRuntimeHost( ).then((home) => assertElectronResolvedIsolatedHome(home, isolation)) ]) let serveProcess = app + // Why: a failed relaunch leaves only the already-closed app, which dispose must not close again + // (that throw would replace the launch error). + let serveProcessOpen = true return { get app() { return serveProcess @@ -156,16 +172,19 @@ export async function launchHeadlessPairedRuntimeHost( ) } await closeElectronAppForE2E(serveProcess) + serveProcessOpen = false await restartOptions.betweenProcesses?.() const relaunched = await launchServeProcess() serveProcess = relaunched + serveProcessOpen = true await readServeReadiness(relaunched, { requirePairingOffer: false }) }, dispose: async () => { await cleanupHeadlessHostResources([ - () => closeElectronAppForE2E(serveProcess), + ...(serveProcessOpen ? [() => closeElectronAppForE2E(serveProcess)] : []), () => cleanupE2EDaemons(userDataDir), - () => rmSync(userDataDir, { recursive: true, force: true }), + () => preserveProfileLogs(userDataDir), + () => rmSync(userDataDir, PROFILE_REMOVAL), ...(agentBrowserSocketDir ? [ () => @@ -183,7 +202,8 @@ export async function launchHeadlessPairedRuntimeHost( await cleanupHeadlessHostResources([ ...(app ? [() => closeElectronAppForE2E(app)] : []), () => cleanupE2EDaemons(userDataDir), - () => rmSync(userDataDir, { recursive: true, force: true }), + () => preserveProfileLogs(userDataDir), + () => rmSync(userDataDir, PROFILE_REMOVAL), ...(agentBrowserSocketDir ? [() => rmSync(agentBrowserSocketDir, { recursive: true, force: true })] : []) diff --git a/tests/e2e/helpers/orca-serve-cli-host.ts b/tests/e2e/helpers/orca-serve-cli-host.ts new file mode 100644 index 000000000000..a40e3a1447f4 --- /dev/null +++ b/tests/e2e/helpers/orca-serve-cli-host.ts @@ -0,0 +1,153 @@ +/** + * `orca serve` through the real CLI (`out/cli/index.js`), so the host it picks is the CLI's own + * selection: orcad by default, Electron on `ORCA_SERVE_RUNTIME=electron` or a fallback. + */ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { spawnProcess } from '../../../src/shared/child-process/run-process' +import { createElectronHomeIsolation } from './electron-home-isolation' +import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' +import { resolveElectronExecutable } from './daemon-generation-runtime-fixture' + +export type CliServe = { + userDataDir: string + /** The first stdout line: the chosen host's readiness JSON. */ + readiness: Record + stderr: () => string + stop: () => Promise +} + +/** A fresh isolated profile the CLI and whichever host it starts both use. */ +export function cliServeProfile(parent: string): { userDataDir: string; env: NodeJS.ProcessEnv } { + const userDataDir = mkdtempSync(path.join(parent, 'cli-serve-')) + 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 isolation = createElectronHomeIsolation({ + inheritedEnv: cleanEnv, + // The lock flag makes e2e builds take the profile lock this test reads, as the host helper does. + launchEnv: { + NODE_ENV: 'development', + ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1', + ORCA_E2E_HEADLESS: '1' + }, + extraEnv: {}, + userDataDir + }) + return { + userDataDir, + env: { + ...isolation.env, + // How `orca serve` from a dev checkout finds its app (config/scripts/orca-dev.mjs). + ORCA_APP_EXECUTABLE: resolveElectronExecutable(process.cwd()), + ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT: '1', + ORCA_USER_DATA_PATH: userDataDir + } + } +} + +export async function startCliServe( + profile: { userDataDir: string; env: NodeJS.ProcessEnv }, + extraEnv: NodeJS.ProcessEnv = {} +): Promise { + const child = spawnProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'serve', + '--json', + '--port', + '0', + '--pairing-address', + '127.0.0.1' + ], + env: { ...profile.env, ...extraEnv }, + timeoutMs: null + }) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) + // A first run may fetch and verify the pinned Node before orcad starts. + const readiness = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`orca serve not ready: ${stderr}`)), 240_000) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + const ready = parseReadiness(stdout) + if (ready) { + clearTimeout(timer) + resolve(ready) + } + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`orca serve exited ${String(code)}: ${stderr}`)) + }) + }) + return { + userDataDir: profile.userDataDir, + readiness, + stderr: () => stderr, + stop: async () => { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = new Promise((settle) => child.once('exit', settle)) + // POSIX: the CLI forwards SIGTERM to the host it started. Windows has no signal to forward: + // kill() ends only the CLI, so the host holding the profile lock is ended too. + child.kill('SIGTERM') + await exited + if (process.platform === 'win32') { + await killProfileLockHolder(profile.userDataDir) + } + } + } +} + +/** orcad prints its readiness on one line; Electron `--serve-json` pretty-prints it. */ +function parseReadiness(stdout: string): Record | null { + const start = stdout.indexOf('{') + for ( + let end = stdout.indexOf('}', start); + start !== -1 && end !== -1; + end = stdout.indexOf('}', end + 1) + ) { + try { + const parsed: unknown = JSON.parse(stdout.slice(start, end + 1)) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return Object.fromEntries(Object.entries(parsed)) + } + } catch { + // Not a complete object yet; try the next closing brace. + } + } + return null +} + +async function killProfileLockHolder(userDataDir: string): Promise { + let pid: unknown + try { + pid = JSON.parse(readFileSync(path.join(userDataDir, 'orcad.lock'), 'utf8')).pid + } catch { + return + } + if (typeof pid !== 'number') { + return + } + try { + process.kill(pid) + } catch { + return + } + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + } catch { + return + } + await new Promise((settle) => setTimeout(settle, 100)) + } +} diff --git a/tests/e2e/orcad-serve-mode-switch.spec.ts b/tests/e2e/orcad-serve-mode-switch.spec.ts index 634f46cfdf89..3bb0d0741cd9 100644 --- a/tests/e2e/orcad-serve-mode-switch.spec.ts +++ b/tests/e2e/orcad-serve-mode-switch.spec.ts @@ -11,7 +11,7 @@ * tests/e2e/orcad-serve-mode-switch.spec.ts --config tests/playwright.config.ts \ * --project electron-headless --workers=1 */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { runProcess, spawnProcess } from '../../src/shared/child-process/run-process' @@ -28,6 +28,8 @@ import { launchHeadlessPairedRuntimeHost, type HeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' +import { cliServeProfile, startCliServe } from './helpers/orca-serve-cli-host' const RUN = process.env.ORCA_E2E_ORCAD_SERVE === '1' const slotDir = path.resolve('out/orcad') @@ -51,7 +53,7 @@ test.beforeAll(async () => { test.afterAll(() => { if (scratch) { - rmSync(scratch, { recursive: true, force: true }) + rmSync(scratch, { recursive: true, force: true, maxRetries: 50, retryDelay: 100 }) } }) @@ -78,7 +80,9 @@ async function terminal( type OrcadServe = { daemonPid: number; stop: () => Promise } /** `orca serve` on orcad, as the T6-11 launcher runs it, on the host's own profile. */ -async function startOrcadServe(host: HeadlessPairedRuntimeHost): Promise { +async function startOrcadServe( + host: Pick +): Promise { const child = spawnProcess({ program: orcadRuntime!, args: [path.join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], @@ -119,6 +123,26 @@ async function startOrcadServe(host: HeadlessPairedRuntimeHost): Promise { + const other = cliServeProfile(scratch) + const orcad = await startOrcadServe(other) + try { + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + userDataParent: scratch + }) + try { + expect(daemonPid(host.userDataDir)).not.toBe(orcad.daemonPid) + } finally { + await host.dispose() + } + } finally { + await orcad.stop() + await cleanupE2EDaemons(other.userDataDir) + } +}) + test('a terminal survives Electron serve → orcad serve → Electron serve on one profile', async () => { const host = await launchHeadlessPairedRuntimeHost({ pinnedServePort: true, @@ -237,6 +261,43 @@ test('each serve host refuses a profile the other holds', async () => { } }) +/** Which host holds the profile: both take `orcad.lock`, each under its own role. */ +function profileLockRole(userDataDir: string): unknown { + return JSON.parse(readFileSync(path.join(userDataDir, 'orcad.lock'), 'utf8')).role +} + +// Needs out/orcad-template for this runner's target, as a packaged install carries. +test('`orca serve` runs on orcad by default and on Electron with ORCA_SERVE_RUNTIME=electron', async () => { + const profile = cliServeProfile(scratch) + try { + const byDefault = await startCliServe(profile) + try { + // Windows still defaults to Electron serve; orcad is opt-in there. + if (process.platform === 'win32') { + expect(byDefault.stderr()).toContain('not the default on Windows yet') + expect(byDefault.readiness.health).toBeUndefined() + } else { + expect(byDefault.stderr()).toContain('[serve] running on orcad') + expect(byDefault.readiness.health).toBeDefined() + expect(profileLockRole(profile.userDataDir)).toBe('orcad') + } + } finally { + await byDefault.stop() + } + + // Electron serve publishes no build-health block yet, which is what tells the two apart. + const electron = await startCliServe(profile, { ORCA_SERVE_RUNTIME: 'electron' }) + try { + expect(electron.stderr()).not.toContain('[serve] running on orcad') + expect(electron.readiness.health).toBeUndefined() + } finally { + await electron.stop() + } + } finally { + await cleanupE2EDaemons(profile.userDataDir) + } +}) + function isAlive(pid: number): boolean { try { process.kill(pid, 0)