Files
orca/tests/tools/win-update-e2e/serve-switch.mjs
T

226 lines
8.8 KiB
JavaScript

// 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(?:-profiles)?\\/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(
'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()