From 8409d1853ed41c4b5c8e3de67b34c6a82567093d Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 00:31:12 -0700 Subject: [PATCH 1/3] test(serve): prove D7 on an installed Windows app whose daemon runs from the relocated daemon-host --- .../workflows/win-orcad-serve-switch-e2e.yml | 104 +++++++++ ...undle-packaging-workflow-contract.test.mjs | 1 + tests/tools/win-update-e2e/serve-switch.mjs | 202 ++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 .github/workflows/win-orcad-serve-switch-e2e.yml create mode 100644 tests/tools/win-update-e2e/serve-switch.mjs diff --git a/.github/workflows/win-orcad-serve-switch-e2e.yml b/.github/workflows/win-orcad-serve-switch-e2e.yml new file mode 100644 index 000000000000..19ed618dfb5e --- /dev/null +++ b/.github/workflows/win-orcad-serve-switch-e2e.yml @@ -0,0 +1,104 @@ +name: Windows packaged orcad serve switch E2E + +# Why: proves D7 on a real Windows install. The installed desktop app forks its terminal daemon +# from the relocated %LOCALAPPDATA%\Orca\daemon-host; `orca serve` then serves the same profile +# on orcad and must adopt that daemon, and the desktop must reattach the terminal afterwards and +# still write its settings under orcad's data-root ACL. Unpackaged e2e cannot exercise the +# relocation, which only packaged builds do. A CI runner is the only safe place to install. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'src/main/daemon/**' + - 'src/main/orcad/**' + - 'src/cli/runtime/**' + - 'src/shared/orcad-local-serve-selection.ts' + - 'tests/tools/win-update-e2e/**' + - '.github/workflows/win-orcad-serve-switch-e2e.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: win-orcad-serve-switch-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + serve-switch: + name: packaged Windows Electron/orcad serve switch (D7) + if: github.event_name != 'pull_request' || github.event.pull_request.draft != true + runs-on: windows-2022 + timeout-minutes: 75 + env: + NODE_OPTIONS: --max-old-space-size=4096 + ORCA_BACKGROUND_LAUNCH: '1' + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + # Same key inputs as win-update-survival-e2e: a harness-only edit skips the installer build. + - name: Cache branch installer + id: cache-installer + uses: actions/cache/restore@v4 + with: + path: dist/orca-windows-setup.exe + key: serve-switch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/**', 'mobile/**', 'patches/**', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + + # Before the template exists: packaging verifies a present template for every target. + - name: Build Windows installer (unsigned) + if: steps.cache-installer.outputs.cache-hit != 'true' + shell: bash + run: | + node config/scripts/ensure-native-runtime.mjs --runtime=electron + pnpm run build:desktop + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never + + - name: Cache the built installer + if: steps.cache-installer.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist/orca-windows-setup.exe + key: ${{ steps.cache-installer.outputs.cache-primary-key }} + + # The win32-x64 template the harness drops into the install, which is where serve looks. + - uses: ./.github/actions/prepare-orcad-prebuilds + with: + resolve-windows-cache: 'true' + restore-windows-cache: 'true' + - name: Build the win32-x64 orcad template + shell: bash + run: | + node config/scripts/ensure-native-runtime.mjs --runtime=node + 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 the installed app's profile + shell: bash + run: | + mkdir -p artifacts + node tests/tools/win-update-e2e/serve-switch.mjs \ + --installer dist/orca-windows-setup.exe \ + --template out/orcad-template 2>&1 | tee artifacts/serve-switch.log + exit "${PIPESTATUS[0]}" + + - name: Upload serve switch output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-orcad-serve-switch-output + path: artifacts/ + retention-days: 7 + if-no-files-found: warn diff --git a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs index f719784d115c..17e44bf0e838 100644 --- a/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs +++ b/config/scripts/mobile-web-bundle-packaging-workflow-contract.test.mjs @@ -96,6 +96,7 @@ const EXPECTED_PACKAGING_JOBS = [ 'release-cut.yml build', 'release-mac-build.yml build-mac', 'win-crash-survival-e2e.yml crash-survival', + 'win-orcad-serve-switch-e2e.yml serve-switch', 'win-update-survival-e2e.yml survival', 'windows-signing-rehearsal.yml rehearse' ] diff --git a/tests/tools/win-update-e2e/serve-switch.mjs b/tests/tools/win-update-e2e/serve-switch.mjs new file mode 100644 index 000000000000..51528ed83258 --- /dev/null +++ b/tests/tools/win-update-e2e/serve-switch.mjs @@ -0,0 +1,202 @@ +// Packaged-Windows D7: a terminal the installed desktop app opened (its daemon forked from the +// relocated %LOCALAPPDATA%\Orca\daemon-host) survives `orca serve` taking the same profile on +// orcad, and the desktop reattaches it afterwards. Also proves the desktop still writes its +// settings once orcad has applied its data-root ACL. Windows-only; run on a disposable runner. +// +// node tests/tools/win-update-e2e/serve-switch.mjs --installer dist/orca-windows-setup.exe \ +// --template out/orcad-template + +import { execFileSync, spawn } from 'node:child_process' +import { cpSync, mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { assertWin32 } from './platform-guard.mjs' +import { silentInstall, silentUninstall } from './installer-steps.mjs' +import { + launchInstalledApp, + ensureTerminal, + dismissOverlays, + startMarker, + readTerminalTextBestEffort, + closeApp, + captureFailureDiagnostics +} from './app-driver.mjs' +import { readDaemonPidFiles, findDaemonProcesses, isPidAlive } from './daemon-processes.mjs' +import { probeHeartbeatAdvancing } from './interactivity-probes.mjs' +import { createSeededRepo, buildFreshProfile } from './onboarding-profile.mjs' + +const log = (step, msg) => console.log(`[serve-switch] ${step}: ${msg}`) +const checks = [] +const check = (name, ok, detail = '') => { + checks.push({ name, ok: Boolean(ok) }) + log(ok ? 'PASS' : 'FAIL', `${name}${detail ? ` (${detail})` : ''}`) +} + +function argValue(flag) { + const index = process.argv.indexOf(flag) + return index === -1 ? null : process.argv[index + 1] +} + +function scopedDaemon(userDataDir) { + const record = readDaemonPidFiles(userDataDir).find((entry) => typeof entry.pid === 'number') + const proc = findDaemonProcesses(userDataDir).find((entry) => entry.pid === record?.pid) + return { pid: record?.pid ?? null, commandLine: proc?.commandLine ?? '' } +} + +function killPid(pid, { tree = false } = {}) { + if (Number.isInteger(pid) && pid > 0) { + try { + execFileSync('taskkill', ['/pid', String(pid), ...(tree ? ['/T'] : []), '/F'], { + stdio: 'ignore' + }) + } catch { + /* already gone */ + } + } +} + +/** `orca serve` through the installed app's own CLI, on the desktop's profile. */ +async function startOrcaServe(exePath, userDataDir) { + const cli = path.join(path.dirname(exePath), 'resources', 'app.asar', 'out', 'cli', 'index.js') + const child = spawn( + exePath, + [cli, 'serve', '--json', '--port', '0', '--pairing-address', '127.0.0.1'], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ORCA_USER_DATA_PATH: userDataDir }, + windowsHide: true + } + ) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk) => (stderr += chunk)) + const readiness = await new Promise((resolve, reject) => { + // A first run fetches and verifies the pinned Node before orcad starts. + const timer = setTimeout(() => reject(new Error(`orca serve not ready: ${stderr}`)), 300_000) + child.stdout.on('data', (chunk) => { + stdout += chunk + const line = stdout.split('\n').find((candidate) => candidate.trim().startsWith('{')) + if (line) { + clearTimeout(timer) + resolve(JSON.parse(line)) + } + }) + child.once('exit', (code) => { + clearTimeout(timer) + reject(new Error(`orca serve exited ${code}: ${stderr}`)) + }) + }) + return { child, readiness, stderr: () => stderr } +} + +/** Windows cannot forward a stop: end the CLI, then orcad (the lock holder), never the daemon. */ +function stopOrcaServe(serve, userDataDir) { + killPid(serve.child.pid) + try { + killPid(JSON.parse(readFileSync(path.join(userDataDir, 'orcad.lock'), 'utf8')).pid) + } catch { + /* orcad already released the profile */ + } +} + +async function run(ctx) { + const installer = argValue('--installer') + const template = argValue('--template') + const runDir = mkdtempSync(path.join(tmpdir(), 'orca-serve-switch-')) + const userDataDir = path.join(runDir, 'userData') + const heartbeatFile = path.join(runDir, 'heartbeat.txt') + const canary = `ORCA-SERVE-SWITCH-${Date.now()}` + ctx.userDataDir = userDataDir + + const installed = silentInstall(installer) + ctx.installDir = path.dirname(installed.exePath) + // The per-target template packaging would verify in full; this leg needs only win32-x64. + cpSync(template, path.join(ctx.installDir, 'resources', 'orcad-template'), { recursive: true }) + log('install', `${installed.exePath} (${installed.version})`) + + const seedProfile = buildFreshProfile({ repo: createSeededRepo(path.join(runDir, 'repo')) }) + ctx.session = await launchInstalledApp({ exePath: installed.exePath, userDataDir, seedProfile }) + await ensureTerminal(ctx.session.page, { allowCreate: true }) + await dismissOverlays(ctx.session.page) + await startMarker(ctx.session.page, { + canary, + pidFile: path.join(runDir, 'marker.pid'), + heartbeatFile + }) + check('desktop terminal heartbeat advancing', await probeHeartbeatAdvancing(heartbeatFile)) + const before = scopedDaemon(userDataDir) + ctx.daemonPid = before.pid + check( + 'desktop daemon runs from the relocated daemon-host', + /\\Orca\\daemon-host\\/i.test(before.commandLine), + before.commandLine.slice(0, 160) + ) + await closeApp(ctx.session.app, 45_000, { allowForceKill: false }) + ctx.session = null + check('daemon outlives the desktop', isPidAlive(before.pid), `pid ${before.pid}`) + + const serve = await startOrcaServe(installed.exePath, userDataDir) + ctx.serve = serve + const daemon = serve.readiness.health?.terminalDaemon + check('orca serve chose orcad', serve.stderr().includes('[serve] running on orcad')) + check( + 'orcad adopted the relocated daemon', + daemon?.state === 'live' && daemon.pid === before.pid, + `${daemon?.state} pid ${daemon?.pid}` + ) + check('terminal keeps running under orcad', await probeHeartbeatAdvancing(heartbeatFile)) + log('acl', execFileSync('icacls', [userDataDir], { encoding: 'utf8' }).trim()) + stopOrcaServe(serve, userDataDir) + ctx.serve = null + check('daemon outlives orcad', isPidAlive(before.pid)) + + ctx.session = await launchInstalledApp({ exePath: installed.exePath, userDataDir }) + await ensureTerminal(ctx.session.page, { allowCreate: false }) + check('desktop reattached the same daemon', scopedDaemon(userDataDir).pid === before.pid) + check( + 'desktop shows the pre-switch terminal', + (await readTerminalTextBestEffort(ctx.session.page)).includes(canary) + ) + check('terminal still running after switching back', await probeHeartbeatAdvancing(heartbeatFile)) + // Durable, not just accepted: write, restart the desktop, and read it back. + await ctx.session.page.evaluate(() => window.api.settings.set({ terminalFontSize: 15 })) + await closeApp(ctx.session.app, 45_000, { allowForceKill: false }) + ctx.session = await launchInstalledApp({ exePath: installed.exePath, userDataDir }) + const fontSize = await ctx.session.page.evaluate( + async () => (await window.api.settings.get()).terminalFontSize + ) + check('desktop persists settings under the orcad data-root ACL', fontSize === 15, `${fontSize}`) +} + +async function main() { + assertWin32('serve-switch') + const ctx = { session: null, serve: null, installDir: null, userDataDir: null, daemonPid: null } + let failed = false + try { + await run(ctx) + } catch (error) { + failed = true + console.error(`[serve-switch] FATAL: ${error.stack ?? error.message}`) + if (ctx.session?.page) { + await captureFailureDiagnostics(ctx.session.page, 'artifacts/diag', 'serve-switch') + } + } finally { + await closeApp(ctx.session?.app).catch(() => {}) + if (ctx.serve) { + stopOrcaServe(ctx.serve, ctx.userDataDir) + } + // The daemon's tree holds the marker loop the terminal ran. + killPid(ctx.daemonPid, { tree: true }) + if (ctx.installDir) { + try { + silentUninstall(ctx.installDir, { allowDefaultLocation: true }) + } catch (error) { + log('teardown', `uninstall failed: ${error.message}`) + } + } + } + const passed = !failed && checks.length > 0 && checks.every((entry) => entry.ok) + log('result', passed ? 'PASS' : 'FAIL') + return passed ? 0 : 1 +} + +process.exitCode = await main() From f69cdc3066be6d47f3ad9e64c0d1013aee3b6b31 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 00:56:09 -0700 Subject: [PATCH 2/3] test(serve): read the pre-switch scrollback best-effort and wait for it after reattach --- tests/tools/win-update-e2e/serve-switch.mjs | 26 +++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/tools/win-update-e2e/serve-switch.mjs b/tests/tools/win-update-e2e/serve-switch.mjs index 51528ed83258..6e17043ce132 100644 --- a/tests/tools/win-update-e2e/serve-switch.mjs +++ b/tests/tools/win-update-e2e/serve-switch.mjs @@ -98,6 +98,18 @@ function stopOrcaServe(serve, userDataDir) { } } +/** Restored scrollback paints after the pane reattaches, not with the window. */ +async function waitForText(page, text, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if ((await readTerminalTextBestEffort(page)).includes(text)) { + return true + } + await new Promise((settle) => setTimeout(settle, 1_000)) + } + return false +} + async function run(ctx) { const installer = argValue('--installer') const template = argValue('--template') @@ -123,6 +135,8 @@ async function run(ctx) { heartbeatFile }) check('desktop terminal heartbeat advancing', await probeHeartbeatAdvancing(heartbeatFile)) + // Best effort, as in run.mjs: the text read can be empty under the WebGL renderer. + const canaryReadable = (await readTerminalTextBestEffort(ctx.session.page)).includes(canary) const before = scopedDaemon(userDataDir) ctx.daemonPid = before.pid check( @@ -152,10 +166,14 @@ async function run(ctx) { ctx.session = await launchInstalledApp({ exePath: installed.exePath, userDataDir }) await ensureTerminal(ctx.session.page, { allowCreate: false }) check('desktop reattached the same daemon', scopedDaemon(userDataDir).pid === before.pid) - check( - 'desktop shows the pre-switch terminal', - (await readTerminalTextBestEffort(ctx.session.page)).includes(canary) - ) + if (canaryReadable) { + check('desktop shows the pre-switch terminal', await waitForText(ctx.session.page, canary)) + } else { + log( + 'scrollback', + 'pre-switch text was not readable; reattach is proven by daemon and heartbeat' + ) + } check('terminal still running after switching back', await probeHeartbeatAdvancing(heartbeatFile)) // Durable, not just accepted: write, restart the desktop, and read it back. await ctx.session.page.evaluate(() => window.api.settings.set({ terminalFontSize: 15 })) From a3c0b636718aa2d3c991221e4d082bca75f94153 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 02:56:39 -0700 Subject: [PATCH 3/3] test(serve): opt the packaged Windows serve switch into orcad explicitly --- tests/tools/win-update-e2e/serve-switch.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/tools/win-update-e2e/serve-switch.mjs b/tests/tools/win-update-e2e/serve-switch.mjs index 6e17043ce132..6cef9723826e 100644 --- a/tests/tools/win-update-e2e/serve-switch.mjs +++ b/tests/tools/win-update-e2e/serve-switch.mjs @@ -62,7 +62,13 @@ async function startOrcaServe(exePath, userDataDir) { exePath, [cli, 'serve', '--json', '--port', '0', '--pairing-address', '127.0.0.1'], { - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', ORCA_USER_DATA_PATH: userDataDir }, + // orcad is opt-in on Windows until Electron serve can adopt a daemon orcad forked. + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_SERVE_RUNTIME: 'orcad', + ORCA_USER_DATA_PATH: userDataDir + }, windowsHide: true } )