Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/win-orcad-serve-switch-e2e.yml
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
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]
Expand Down
226 changes: 226 additions & 0 deletions tests/tools/win-update-e2e/serve-switch.mjs
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))
}
})
Comment on lines +81 to +88

Copy link
Copy Markdown
Contributor

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.parse would then throw inside the data handler, 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-71 buffers with a StringDecoder and only parses once a \n arrives).

Technical details
# Readiness JSON parsed before its line is complete

## Affected sites
- tests/tools/win-update-e2e/serve-switch.mjs:75-82 — `stdout.split('\n').find(...startsWith('{'))` then `JSON.parse(line)`
- src/cli/runtime/serve-recipe-json.ts:59-71 — newline-framed reads with a `StringDecoder`

## Required outcome
- Only parse a stdout line after its terminating newline has arrived (or catch `JSON.parse` and keep buffering); never let a partial chunk throw out of the `data` handler.
Suggested change
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))
}
})
let pending = ''
child.stdout.on('data', (chunk) => {
stdout += chunk
pending += chunk
let newlineIndex
while ((newlineIndex = pending.indexOf('\n')) !== -1) {
const line = pending.slice(0, newlineIndex)
pending = pending.slice(newlineIndex + 1)
if (line.trim().startsWith('{')) {
clearTimeout(timer)
resolve(JSON.parse(line))
return
}
}
})

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()
Loading