From 0c4a7af989f4345b288180516f0ec08a49d19711 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 01:03:11 -0700 Subject: [PATCH 1/2] test(serve): prove D7 and the profile lock across a real Electron/orcad serve switch --- .github/workflows/e2e.yml | 45 +++- config/scripts/ci-e2e-job-selection.mjs | 12 +- config/scripts/pr-e2e-source-routing.mjs | 9 + .../scripts/ssh-docker-ci-sharding.test.mjs | 1 + .../daemon-generation-runtime-fixture.ts | 2 +- .../helpers/headless-paired-runtime-host.ts | 6 + tests/e2e/orcad-serve-mode-switch.spec.ts | 247 ++++++++++++++++++ 7 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/orcad-serve-mode-switch.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8dad06a1faff..3ba92ea340c6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -320,7 +320,8 @@ jobs: . != "tests/e2e/paired-startup-exec-readiness.spec.ts" and . != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and . != "tests/e2e/ssh-localhost.spec.ts" and - . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" + . != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and + . != "tests/e2e/orcad-serve-mode-switch.spec.ts" )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs" @@ -487,6 +488,48 @@ jobs: ORCA_RUN_DOCKER_SSH_BROWSER_E2E: '1' run: node_modules/.bin/vitest run --config config/vitest.config.ts tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts + orcad-serve-mode-switch: + name: orca serve Electron/orcad mode switch (D7) + needs: [build, prepare-native-cache] + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + ORCA_BACKGROUND_LAUNCH: '1' + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + persist-credentials: false + - name: Install headless tools + run: sudo apt-get update && sudo apt-get install -y build-essential xvfb openbox x11-utils + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - uses: actions/download-artifact@v8 + with: + 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 + run: | + pnpm build:orcad-prebuilds + pnpm build:orcad-prebuilds --require-slots "$(node config/scripts/build-orcad-prebuilds.mjs --print-slot)" + pnpm build:orcad + - name: Switch serve hosts on one profile + env: + SKIP_BUILD: '1' + ORCA_E2E_ORCAD_SERVE: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh 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-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/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index 6126e753b5dc..69887908ce1e 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -37,11 +37,14 @@ export const NODE_NETWORK_E2E_SPEC = 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts' export const LOCALHOST_SSH_E2E_SPEC = 'tests/e2e/ssh-localhost.spec.ts' export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.ts' +// Needs the packaged orcad slot, which only its own job builds. +export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts' export const DEDICATED_E2E_SPECS = [ ...DOCKER_SSH_E2E_SPECS, NODE_NETWORK_E2E_SPEC, LOCALHOST_SSH_E2E_SPEC, - NATIVE_IME_E2E_SPEC + NATIVE_IME_E2E_SPEC, + ORCAD_SERVE_MODE_SWITCH_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) const dockerSpecs = new Set(DOCKER_SSH_E2E_SPECS) @@ -76,7 +79,12 @@ export function classifyE2eJobs(input, sshSourceChanged = 'false') { e2e_needs_build: runChanged || sshSourceChanged !== 'false' || - specs.some((spec) => dockerSpecs.has(spec) || spec === LOCALHOST_SSH_E2E_SPEC) + specs.some( + (spec) => + dockerSpecs.has(spec) || + spec === LOCALHOST_SSH_E2E_SPEC || + spec === ORCAD_SERVE_MODE_SWITCH_E2E_SPEC + ) } } diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index f8737ad6ea62..ff635c149d46 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -13,6 +13,15 @@ const NATIVE_IME_HARNESS = /^(?:config\/scripts\/focus-nested-wayland-terminal\.sh$|config\/scripts\/(?:run-terminal-ibus-hangul-e2e|terminal-ime-engagement-receipt)\.mjs$|tests\/e2e\/terminal-ime-(?:boundary-probe|byte-reader|engagement-receipt)\.ts$|tests\/e2e\/terminal-(?:ibus-hangul|hangul-terminating-digit|macos-2set-korean)-native\.spec\.ts$)/ export const PR_E2E_SOURCE_ROUTES = [ + { + id: 'serve.orcad-mode-switch', + specs: ['tests/e2e/orcad-serve-mode-switch.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/(?:cli\/runtime\/(?:launch|serve-)|main\/orcad\/(?:main|orcad-entry|orcad-instance-lock|orcad-command-arguments|orcad-lifecycle)\.ts$|main\/startup\/desktop-profile-instance-lock\.ts$|main\/daemon\/daemon-(?:spawner|endpoint-adoption|init)|main\/server\/serve-)/.test( + file + ) + }, { id: 'ssh.localhost-agent-hooks', specs: ['tests/e2e/ssh-localhost.spec.ts'], diff --git a/config/scripts/ssh-docker-ci-sharding.test.mjs b/config/scripts/ssh-docker-ci-sharding.test.mjs index 321e1c6ad341..05a1f520613f 100644 --- a/config/scripts/ssh-docker-ci-sharding.test.mjs +++ b/config/scripts/ssh-docker-ci-sharding.test.mjs @@ -33,6 +33,7 @@ it('gives every removed SSH spec a dedicated owner even for test-only edits', () expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) } expect(excluded.filter((spec) => !owned.includes(spec)).sort()).toEqual([ + 'tests/e2e/orcad-serve-mode-switch.spec.ts', 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts', 'tests/e2e/ssh-localhost.spec.ts', 'tests/e2e/terminal-ibus-hangul-native.spec.ts' diff --git a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts index 332d21aa2712..d7128d1d2711 100644 --- a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts +++ b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts @@ -78,7 +78,7 @@ function assertDisposableRoot(rootDir: string): void { } } -function resolveElectronExecutable(repoRoot: string): string { +export function resolveElectronExecutable(repoRoot: string): string { const relativePath = readFileSync( path.join(repoRoot, 'node_modules', 'electron', 'path.txt'), 'utf8' diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 07cecbc8d051..560a0a5dfd52 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -36,6 +36,10 @@ export type HeadlessPairedRuntimeHost = { betweenProcesses?: () => void | Promise }) => Promise userDataDir: string + /** The isolated launch environment, for another host started on the same profile. */ + env: NodeJS.ProcessEnv + /** Electron's own argv for this serve host, without the serve flags. */ + electronArgs: string[] } type HeadlessHostCleanup = () => Promise | void @@ -143,6 +147,8 @@ export async function launchHeadlessPairedRuntimeHost( client: new RuntimeClient(userDataDir, 5_000), offer, userDataDir, + env: isolation.env, + electronArgs: options.executablePath ? [] : getOrcaElectronLaunchArgs(mainPath, false), restartServeProcess: async (restartOptions = {}) => { if (options.pinnedServePort !== true) { throw new Error( diff --git a/tests/e2e/orcad-serve-mode-switch.spec.ts b/tests/e2e/orcad-serve-mode-switch.spec.ts new file mode 100644 index 000000000000..634f46cfdf89 --- /dev/null +++ b/tests/e2e/orcad-serve-mode-switch.spec.ts @@ -0,0 +1,247 @@ +/** + * D7 across a real `orca serve` host switch: a terminal opened under Electron serve survives a + * switch to orcad serve on the same profile (same daemon adopted, terminal reattached) and back, + * and a terminal orcad's daemon owns survives a switch to Electron serve. Also the shared-profile + * lock: each host refuses to serve a profile the other holds. + * + * Needs the packaged orcad slot (`pnpm build:orcad`). Background launch only; nothing is focused. + * + * Run: + * ORCA_E2E_ORCAD_SERVE=1 ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test \ + * 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 os from 'node:os' +import path from 'node:path' +import { runProcess, spawnProcess } from '../../src/shared/child-process/run-process' +import { getDaemonPidPath } from '../../src/main/daemon/daemon-spawner' +import { readDaemonPidRecord } from '../../src/main/daemon/daemon-endpoint-incarnation' +import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime' +import { + buildDaemonSessionClient, + type DaemonSessionClientResult +} from '../../src/main/orcad/orcad-daemon-session-client-fixture' +import { expect, test } from './helpers/orca-app' +import { resolveElectronExecutable } from './helpers/daemon-generation-runtime-fixture' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' + +const RUN = process.env.ORCA_E2E_ORCAD_SERVE === '1' +const slotDir = path.resolve('out/orcad') +const orcadRuntime = RUN && existsSync(slotDir) ? resolveBundledOrcadRuntime(slotDir) : null +const SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE = 3 +const ORCAD_EXIT_CONFIGURATION = 78 + +test.describe.configure({ mode: 'serial' }) + +let scratch = '' +let clientScript = '' + +test.beforeAll(async () => { + test.skip(!RUN, 'Set ORCA_E2E_ORCAD_SERVE=1 (and build out/orcad) to run the serve mode switch.') + expect(orcadRuntime, 'pnpm build:orcad must produce a packaged slot in out/orcad').toBeTruthy() + // Short root: macOS caps a unix socket path at 104 bytes, and the daemon's lives in userData. + scratch = mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'orca-d7-')) + clientScript = path.join(scratch, 'daemon-client.cjs') + await buildDaemonSessionClient(clientScript) +}) + +test.afterAll(() => { + if (scratch) { + rmSync(scratch, { recursive: true, force: true }) + } +}) + +function daemonPid(userDataDir: string): number | null { + return readDaemonPidRecord(getDaemonPidPath(path.join(userDataDir, 'daemon')))?.pid ?? null +} + +/** A paired client's terminal, straight to the profile's daemon, under this runner's Node. */ +async function terminal( + host: HeadlessPairedRuntimeHost, + op: 'create' | 'attach', + marker: string +): Promise { + const result = await runProcess({ + program: process.execPath, + args: [clientScript, op, path.join(host.userDataDir, 'daemon'), 'serve-d7', marker, scratch], + env: host.env, + timeoutMs: 30_000 + }) + expect(result.code, result.stderr).toBe(0) + return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') +} + +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 { + const child = spawnProcess({ + program: orcadRuntime!, + args: [path.join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], + env: { ...host.env, ORCA_USER_DATA: host.userDataDir }, + timeoutMs: null + }) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8'))) + const line = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`orcad serve not ready: ${stderr}`)), 120_000) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8') + const newline = stdout.indexOf('\n') + if (newline !== -1) { + clearTimeout(timer) + resolve(stdout.slice(0, newline)) + } + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`orcad serve exited ${String(code)}: ${stderr}`)) + }) + }) + const daemon = JSON.parse(line).health?.terminalDaemon + expect(daemon?.state, stderr).toBe('live') + return { + daemonPid: daemon.pid, + stop: async () => { + if (child.exitCode !== null) { + return + } + const exited = new Promise((settle) => child.once('exit', settle)) + // POSIX: SIGTERM is orcad's graceful stop and leaves the daemon running by design. + child.kill('SIGTERM') + await exited + } + } +} + +test('a terminal survives Electron serve → orcad serve → Electron serve on one profile', async () => { + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + userDataParent: scratch + }) + try { + const created = await terminal(host, 'create', 'ELECTRON') + expect(created).toMatchObject({ isReattach: false, output: true }) + const electronDaemon = daemonPid(host.userDataDir) + expect(electronDaemon).toBeTruthy() + + await host.restartServeProcess({ + betweenProcesses: async () => { + // Electron serve has quit; orcad serve takes the same profile and adopts its daemon. + const orcad = await startOrcadServe(host) + try { + expect(orcad.daemonPid).toBe(electronDaemon) + expect(await terminal(host, 'attach', 'ORCAD')).toEqual({ + pid: created.pid, + isReattach: true, + output: true + }) + } finally { + await orcad.stop() + } + } + }) + + // Back on Electron serve: still the same daemon and the same live terminal. + expect(daemonPid(host.userDataDir)).toBe(electronDaemon) + expect(await terminal(host, 'attach', 'BACK')).toEqual({ + pid: created.pid, + isReattach: true, + output: true + }) + } finally { + await host.dispose() + } +}) + +test("Electron serve adopts a terminal orcad's daemon owns", async () => { + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + userDataParent: scratch + }) + try { + let created: DaemonSessionClientResult | null = null + let orcadDaemon: number | null = null + await host.restartServeProcess({ + betweenProcesses: async () => { + // Retire Electron's daemon so orcad serve forks its own on the pinned Node. + const previous = daemonPid(host.userDataDir) + if (previous) { + process.kill(previous, 'SIGKILL') + await expect.poll(() => isAlive(previous), { timeout: 10_000 }).toBe(false) + } + const orcad = await startOrcadServe(host) + try { + orcadDaemon = orcad.daemonPid + expect(orcadDaemon).not.toBe(previous) + created = await terminal(host, 'create', 'ORCAD') + expect(created).toMatchObject({ isReattach: false, output: true }) + } finally { + await orcad.stop() + } + } + }) + expect(daemonPid(host.userDataDir)).toBe(orcadDaemon) + expect(await terminal(host, 'attach', 'ELECTRON')).toEqual({ + pid: created!.pid, + isReattach: true, + output: true + }) + } finally { + await host.dispose() + } +}) + +test('each serve host refuses a profile the other holds', async () => { + const host = await launchHeadlessPairedRuntimeHost({ + pinnedServePort: true, + userDataParent: scratch + }) + try { + // Electron serve holds the profile: orcad serve refuses with its configuration exit code. + const refused = await runProcess({ + program: orcadRuntime!, + args: [path.join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'], + env: { ...host.env, ORCA_USER_DATA: host.userDataDir }, + timeoutMs: 60_000 + }) + expect(refused.code).toBe(ORCAD_EXIT_CONFIGURATION) + expect(refused.stdout).toBe('') + expect(refused.stderr).toContain('The Orca desktop app') + + await host.restartServeProcess({ + betweenProcesses: async () => { + // orcad serve holds it: an Electron launch on the same profile exits as a duplicate. + const orcad = await startOrcadServe(host) + try { + const electron = await runProcess({ + program: resolveElectronExecutable(process.cwd()), + args: [...host.electronArgs, '--serve', '--serve-json', '--serve-port', '0'], + env: host.env, + timeoutMs: 90_000 + }) + expect(electron.code).toBe(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + expect(`${electron.stdout}${electron.stderr}`).toContain('Another orcad') + } finally { + await orcad.stop() + } + } + }) + } finally { + await host.dispose() + } +}) + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} From 6de08429508f21fe1e2ff5f33c0757de006c4afc Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 2 Oct 2026 02:29:39 -0700 Subject: [PATCH 2/2] ci(e2e): install ripgrep for the serve mode-switch job's window-manager wait --- .github/workflows/e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3ba92ea340c6..1b423725c40c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -502,7 +502,7 @@ jobs: ref: ${{ inputs.ref || github.ref }} persist-credentials: false - name: Install headless tools - run: sudo apt-get update && sudo apt-get install -y build-essential xvfb openbox x11-utils + run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils - uses: ./.github/actions/install-node-dependencies with: native-runtime: electron