Repository navigation
test(serve): prove D7 on an installed Windows app whose daemon runs from the relocated daemon-host #24976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
OrcaWin
merged 3 commits into
OrcaWin/node-rt-phase3
from
OrcaWin/node-rt-p3-serve-switch-packaged-win
Oct 4, 2026
Merged
test(serve): prove D7 on an installed Windows app whose daemon runs from the relocated daemon-host #24976
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,226 @@ | ||
| // 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'], | ||
| { | ||
| // 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 | ||
| } | ||
| ) | ||
| 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 */ | ||
| } | ||
| } | ||
|
|
||
| /** 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') | ||
| 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)) | ||
| // 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( | ||
| '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) | ||
| 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 })) | ||
| 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() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The readiness parse treats any line starting with
{as complete, but a pipe read can split the readiness JSON mid-object;JSON.parsewould then throw inside thedatahandler, which is not in the awaited promise chain, so the harness dies with an uncaught exception instead of reporting a clean failure. The production reader frames the line first (src/cli/runtime/serve-recipe-json.ts:59-71buffers with aStringDecoderand only parses once a\narrives).Technical details