Files
2026-07-13 13:05:33 +08:00

629 lines
22 KiB
JavaScript

// win-update-e2e — packaged NSIS update proof harness.
//
// Given two Orca Windows installers (version N and N+1), proves what happens to
// the terminal daemon and its sessions across a real silent update, with
// machine-checkable assertions. Windows-only. See README.md for usage and the
// design context in docs/windows-terminal-update-survival-plan.md (Phase 0).
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { assertWin32 } from './platform-guard.mjs'
import { parseArgs } from './cli-args.mjs'
import { preflight } from './preflight.mjs'
import { resolveInstaller, silentInstall, silentUninstall } from './installer-steps.mjs'
import { backupInstallState, restoreInstallState } from './registry-shortcut-backup.mjs'
import {
launchInstalledApp,
ensureTerminal,
dismissOverlays,
createTerminalTab,
listTabIds,
startMarker,
readTerminalTextBestEffort,
closeApp,
captureFailureDiagnostics
} from './app-driver.mjs'
import { readDaemonPidFiles, findDaemonProcesses, isPidAlive } from './daemon-processes.mjs'
import { startWatch } from './window-watch.mjs'
import {
probeEcho,
probeHeartbeatAdvancing,
probeCtrlCInterruptsMarker,
probeCtrlCOnFreshLoop,
fileMtimeMs
} from './interactivity-probes.mjs'
import { buildAssertions, renderTable, allPassed } from './assertions.mjs'
import { createSeededRepo, buildFreshProfile } from './onboarding-profile.mjs'
function log(step, msg) {
console.log(`[win-update-e2e] ${step}: ${msg}`)
}
async function main() {
const opts = parseArgs(process.argv.slice(2))
if (opts.help) {
console.log(opts.usage)
return 0
}
if (opts.errors?.length) {
console.error(`Argument errors:\n - ${opts.errors.join('\n - ')}\n${opts.usage}`)
return 2
}
assertWin32('win-update-e2e')
const installDir = opts.installDir ?? null
const isolated = Boolean(installDir)
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const canary = `ORCA-E2E-CANARY-${runId}`
const runDir = mkdtempSync(path.join(tmpdir(), `orca-win-update-e2e-${runId}-`))
const userDataDir = path.join(runDir, 'userData')
const baselinePath = path.join(runDir, 'baseline.json')
const watchOut = path.join(runDir, 'window-watch.jsonl')
const markerPidFile = path.join(runDir, 'marker.pid')
const heartbeatFile = path.join(runDir, 'heartbeat.txt')
const created = { markerPid: null, daemonPids: new Set() }
log(
'setup',
`runId=${runId} runDir=${runDir} profile=${opts.expect}${isolated ? ` installDir=${installDir}` : ''}`
)
if (isolated && opts.keepInstall) {
log(
'setup',
'--keep-install is ignored in isolated mode: the test install and its per-user ' +
'registry hijack are always cleaned up so the real install stays safe.'
)
}
const { warnings, existingInstall } = preflight({
baselinePath,
allowExistingInstall: opts.allowExistingInstall,
installDir
})
const hadPreexistingInstall = Boolean(existingInstall)
for (const w of warnings) {
log('preflight-warning', w)
}
// Isolated mode: snapshot the shared per-user registry keys + shortcuts BEFORE
// any install writes over them. Everything after this must run through the
// try/finally so the snapshot is always restored, even on failure.
let manifest = null
if (isolated) {
manifest = backupInstallState(runDir)
log('isolated', `backed up install registry/shortcut state -> ${manifest.backupDir}`)
}
const runArgs = {
opts,
installDir,
canary,
runDir,
userDataDir,
baselinePath,
watchOut,
markerPidFile,
heartbeatFile,
created
}
// Both paths ALWAYS tear down (close the app, kill the marker/watch, uninstall)
// and, in isolated mode, restore registry/shortcuts — whatever happens in the
// proof body. A driving failure captures diagnostics first, then surfaces as a
// FATAL. Teardown in a finally is what keeps a driving hang from pinning the
// Node process alive until the CI job timeout.
const ctx = { session: null }
const diagDir = process.env.ORCA_E2E_DIAG_DIR || path.join(runDir, 'diag')
let passed = false
try {
passed = await runProof(ctx, runArgs)
} catch (err) {
console.error(`[win-update-e2e] FATAL: ${err.stack || err.message}`)
if (ctx.session?.page) {
const diag = await captureFailureDiagnostics(ctx.session.page, diagDir, 'driving-failure')
log('diag', `captured -> ${diagDir} (store=${diag.info?.hasStore ?? 'n/a'})`)
if (diag.info?.bodyText) {
log('diag', `visible text: ${diag.info.bodyText.replace(/\s+/g, ' ').slice(0, 300)}`)
}
}
passed = false
} finally {
await (isolated
? isolatedTeardown({
app: ctx.session?.app,
created,
userDataDir,
installDir,
manifest,
runDir
})
: teardown({
app: ctx.session?.app,
created,
userDataDir,
keepInstall: opts.keepInstall,
hadPreexistingInstall,
installedExePath: ctx.installedExePath ?? null,
runDir
}))
}
return passed ? 0 : 1
}
/**
* Run the install → drive → update → relaunch → assert proof. `ctx.session` is
* assigned as soon as each app launches so a caller's finally can tear down a
* partially-created session on failure. Returns whether every assertion passed.
*/
async function runProof(ctx, args) {
const {
opts,
installDir,
canary,
runDir,
userDataDir,
baselinePath,
watchOut,
markerPidFile,
heartbeatFile,
created
} = args
const fromInstaller = resolveInstaller({
localPath: opts.from,
releaseTag: opts.fromRelease,
assetPattern: opts.assetPattern
})
log('install-base', `installing ${fromInstaller}`)
const base = silentInstall(fromInstaller, { installDir })
// Track the install now (not only after the update at L238) so a failure
// anywhere before the update still tears down the base install this harness
// created, instead of orphaning it and blocking the next run's preflight.
ctx.installedExePath = base.exePath
log('install-base', `installed ${base.exePath} (version ${base.version})`)
// Seed a fresh profile (onboarding dismissed + one throwaway git repo as a
// project) ONLY before the first launch. The relaunch must use the app's own
// persisted state so the cold-restore/survival assertions are meaningful.
const seededRepo = createSeededRepo(path.join(runDir, 'fixture-repo'))
const seedProfile = buildFreshProfile({ repo: seededRepo })
// --- Base version: launch, create sessions, start marker, record daemon ---
let session = await launchInstalledApp({ exePath: base.exePath, userDataDir, seedProfile })
ctx.session = session
// A fresh profile has no terminal yet, so create a workspace + terminal, then
// clear the post-creation overlays that would intercept typing.
await ensureTerminal(session.page, { allowCreate: true })
await dismissOverlays(session.page)
const tabIds = await listTabIds(session.page)
log('sessions', `terminal ready; tab ids: ${tabIds.join(', ')}`)
// Baseline typed-input on the IDLE shell, before the perpetual marker loop
// takes over the foreground (a command typed against a running loop can't
// execute, so this must precede startMarker to be meaningful).
const echoBeforeUpdate = await probeEcho(session.page, runDir, 'echo-pre')
log('sessions', `pre-update echo interactive: ${echoBeforeUpdate}`)
await startMarker(session.page, { canary, pidFile: markerPidFile, heartbeatFile })
const markerLive = await probeHeartbeatAdvancing(heartbeatFile)
created.markerPid = readIntFile(markerPidFile)
log('marker', `pid=${created.markerPid} heartbeatAdvancing=${markerLive}`)
const preDaemon = resolveScopedDaemon(userDataDir)
preDaemon.pids.forEach((p) => created.daemonPids.add(p))
log('daemon', `pre-update daemon pid=${preDaemon.pid} appVersion=${preDaemon.appVersion}`)
log('daemon', `pre-update daemon exe: ${preDaemon.exePath ?? '(unknown)'}`)
const preScrollback = await readTerminalTextBestEffort(session.page)
// Close app normally; the detached daemon must remain alive.
await closeApp(session.app)
const daemonAliveAfterClose = preDaemon.pid != null && isPidAlive(preDaemon.pid)
log('daemon', `alive after app close: ${daemonAliveAfterClose}`)
// --- Start the console-window watch through the whole update + soak ---
const watchDuration = 120 + opts.soakSeconds
const watch = startWatch({ baselinePath, outPath: watchOut, durationSec: watchDuration })
log('watch', `started (duration ${watchDuration}s) -> ${watchOut}`)
// --- Update: install N+1 ---
const toInstaller = resolveInstaller({
localPath: opts.to,
releaseTag: opts.toRelease,
assetPattern: opts.assetPattern
})
log('update', `installing ${toInstaller}`)
const updated = silentInstall(toInstaller, { installDir })
// Record the exact dir the harness installed into so non-isolated teardown
// uninstalls THAT and never scan-discovers the developer's real install.
ctx.installedExePath = updated.exePath
log('update', `installed ${updated.exePath} (version ${updated.version})`)
// --- Relaunch and gather post-update evidence ---
session = await launchInstalledApp({ exePath: updated.exePath, userDataDir })
ctx.session = session
// No create on relaunch: the session must be RESTORED, not freshly made.
await ensureTerminal(session.page, { allowCreate: false })
await dismissOverlays(session.page)
const evidence = await gatherEvidence({
profile: opts.expect,
page: session.page,
runDir,
heartbeatFile,
preDaemon,
userDataDir,
preScrollback,
echoBeforeUpdate
})
evidence.postDaemon?.pids?.forEach((p) => created.daemonPids.add(p))
// --- Soak, then stop the watch and evaluate ---
log('soak', `waiting ${opts.soakSeconds}s for delayed flashes`)
await delay(opts.soakSeconds * 1000)
const { events } = await watch.stop()
log('watch', `recorded ${events.length} new-window events`)
const assertionCtx = {
profile: opts.expect,
canary,
watchEvents: events,
markerPid: created.markerPid,
daemonLog: readDaemonLog(userDataDir),
...evidence
}
const assertions = buildAssertions(assertionCtx)
const passed = allPassed(assertions)
console.log(renderTable(assertions))
log('result', passed ? 'PASS' : 'FAIL')
return passed
}
async function gatherEvidence(args) {
const { profile, page, runDir, heartbeatFile, preDaemon, userDataDir, preScrollback } = args
const postDaemon = resolveScopedDaemon(userDataDir)
if (profile === 'survival') {
// The decisive survival signals: did the SPECIFIC pre-update daemon process
// live through the update, and is the new app running that same relocated
// exe or a fresh in-dir fork? Log both regardless of the assertion outcome.
const preDaemonAliveAfter = preDaemon.pid != null && isPidAlive(preDaemon.pid)
log(
'daemon',
`post-update daemon pid=${postDaemon.pid} exe: ${postDaemon.exePath ?? '(unknown)'}`
)
log(
'daemon',
`pre-update daemon pid=${preDaemon.pid} still alive after update: ${preDaemonAliveAfter}`
)
dumpDaemonLog(userDataDir)
const heartbeatBefore = fileMtimeMs(heartbeatFile)
const heartbeatAdvancedAfterUpdate = await heartbeatAdvancedSince(
heartbeatFile,
heartbeatBefore
)
// Sample marker survival BEFORE interrupting it: the pre-update shell must be
// measured while its heartbeat loop still runs, not after
// probeCtrlCInterruptsMarker deliberately breaks that loop.
const markerAliveAfter = isMarkerAlive(runDir)
log(
'marker',
`pid=${readIntFile(path.join(runDir, 'marker.pid'))} aliveAfterUpdate=${markerAliveAfter}`
)
// Interrupt the foreground loop first so the shell returns to a prompt; only
// then can a freshly-typed command execute. Typing while the infinite
// heartbeat loop owns the shell never runs, regardless of input health — so
// the echo probe is meaningful only after the interrupt.
const ctrlCInterrupted = await probeCtrlCInterruptsMarker(page, runDir, heartbeatFile)
const echoObserved = await probeEcho(page, runDir, 'echo-post')
return {
preDaemonPid: preDaemon.pid,
preDaemonAliveAfter,
postDaemonPid: postDaemon.pid,
postDaemonAlive: postDaemon.pid != null && isPidAlive(postDaemon.pid),
postDaemon,
markerAliveAfter,
heartbeatAdvancedAfterUpdate,
echoObserved,
ctrlCInterrupted
}
}
// cold-restore
const postScrollback = await readTerminalTextBestEffort(page)
const scrollbackRestored = scrollbackFidelity(preScrollback, postScrollback)
await createTerminalTab(page)
const echoObserved = await probeEcho(page, runDir, 'echo-fresh')
const ctrlCInterrupted = await probeCtrlCOnFreshLoop(page, runDir)
return {
preDaemonPid: preDaemon.pid,
preDaemonAliveAfter: preDaemon.pid != null && isPidAlive(preDaemon.pid),
postDaemonPid: postDaemon.pid,
postDaemonAlive: postDaemon.pid != null && isPidAlive(postDaemon.pid),
postDaemon,
scrollbackRestored,
echoObserved,
ctrlCInterrupted
}
}
/**
* Resolve THIS run's daemon, scoped to its isolated userData dir so unrelated
* daemons on the machine are ignored. Prefers the pid file (authoritative,
* carries appVersion); cross-checks the live process scan.
*/
function resolveScopedDaemon(userDataDir) {
const pidFiles = readDaemonPidFiles(userDataDir)
const scan = findDaemonProcesses(userDataDir)
const pids = new Set()
for (const rec of pidFiles) {
if (typeof rec.pid === 'number') {
pids.add(rec.pid)
}
}
for (const proc of scan) {
if (typeof proc.pid === 'number') {
pids.add(proc.pid)
}
}
const primary = pidFiles.find((r) => typeof r.pid === 'number')
const pid = primary?.pid ?? scan[0]?.pid ?? null
const scanEntry = scan.find((p) => p.pid === pid) ?? scan[0]
return {
pid,
appVersion: primary?.appVersion ?? null,
startedAtMs: primary?.startedAtMs ?? null,
// Why: the daemon's exe path (first token of its command line) tells us
// whether it was forked from the relocated userData/daemon-host copy or the
// install-dir Orca.exe — the key survival signal.
exePath: daemonExePath(scanEntry?.commandLine),
pids: [...pids]
}
}
/** Print the daemon's lifecycle log (Phase 0) so its startup/session events are
* visible in the CI log before teardown removes the userData dir. */
function dumpDaemonLog(userDataDir) {
const logPath = path.join(userDataDir, 'logs', 'daemon.log')
try {
const lines = readFileSync(logPath, 'utf8').trim().split('\n')
log('daemon-log', `${logPath} (${lines.length} lines):`)
for (const line of lines.slice(-40)) {
console.log(` ${line}`)
}
} catch {
log('daemon-log', `${logPath} (unavailable)`)
}
}
/** Extract the host exe path (first token) from a daemon command line. */
function daemonExePath(commandLine) {
if (typeof commandLine !== 'string') {
return null
}
const trimmed = commandLine.trim()
if (trimmed.startsWith('"')) {
const end = trimmed.indexOf('"', 1)
return end > 0 ? trimmed.slice(1, end) : null
}
const space = trimmed.indexOf(' ')
return space > 0 ? trimmed.slice(0, space) : trimmed
}
function isMarkerAlive(runDir) {
const pid = readIntFile(path.join(runDir, 'marker.pid'))
return pid != null && isPidAlive(pid)
}
async function heartbeatAdvancedSince(heartbeatFile, sinceMs) {
await delay(1500)
return fileMtimeMs(heartbeatFile) > sinceMs
}
function scrollbackFidelity(before, after) {
// WebGL renderer can leave both empty; report unknown (null) rather than a
// false failure. When text is available, check a stable prefix survived.
if (!before || !before.trim() || !after || !after.trim()) {
return null
}
const marker = before
.trim()
.split('\n')
.find((l) => l.trim().length > 3)
if (!marker) {
return null
}
return after.includes(marker.trim())
}
export function readDaemonLog(userDataDir) {
// The daemon log (when present) is JSONL: { src, ts, pid, event, ...details }.
// Only genuinely-bad records fail a run — matched by EVENT NAME, not by any
// string containing "error". The daemon logs benign 'uncaught-exception-
// suppressed' events with name:"Error" for native PTY errors it intentionally
// swallows (src/main/daemon/daemon-entry.ts); those must never fail, and
// 'client-hello-rejected' with reason expected-hello/protocol-mismatch is
// normal version-skew during an update. Non-JSON lines fall back to a raw
// FATAL match so an unstructured crash dump still counts.
const logPath = path.join(userDataDir, 'logs', 'daemon.log')
if (!existsSync(logPath)) {
return null
}
const errorLines = []
let suppressedCount = 0
for (const raw of readFileSync(logPath, 'utf8').split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
let rec
try {
rec = JSON.parse(line)
} catch {
if (/\bFATAL\b/.test(line)) {
errorLines.push(line)
}
continue
}
if (rec.event === 'uncaught-exception-suppressed') {
suppressedCount += 1
} else if (
rec.event === 'uncaught-exception-fatal' ||
(rec.event === 'client-hello-rejected' && rec.reason === 'invalid-token')
) {
errorLines.push(line)
}
}
return { path: logPath, errorLines, suppressedCount }
}
/**
* Isolated-mode teardown. The harness OWNS the isolated dir by construction, so
* it always uninstalls the test install, then ALWAYS restores the shared per-user
* registry keys + shortcuts the installer hijacked (the uninstaller clears the
* test install's copies; restore re-imports the real install's originals or
* deletes freshly-created keys). Finally removes the emptied install dir + runDir.
*/
async function isolatedTeardown({ app, created, userDataDir, installDir, manifest, runDir }) {
try {
await closeApp(app)
} catch {
/* already closed / never launched */
}
killPid(created.markerPid)
for (const pid of resolveScopedDaemon(userDataDir).pids) {
killPid(pid)
}
for (const pid of created.daemonPids) {
killPid(pid)
}
const uninstalled = silentUninstall(installDir)
log('isolated-teardown', `uninstalled test install: ${uninstalled}`)
try {
const restore = restoreInstallState(manifest)
log(
'isolated-teardown',
`registry restore verified=${restore.verified} imported=[${restore.imported.join(', ')}] ` +
`deleted=[${restore.deleted.join(', ')}] shortcutsRestored=${restore.shortcutsRestored.length} ` +
`shortcutsDeleted=${restore.shortcutsDeleted.length}`
)
} catch (err) {
// Restore must never be silently skipped — surface it loudly and point at the
// manifest so the shared keys can be recovered by hand.
console.error(
`\n*** WIN-UPDATE-E2E: registry/shortcut restore THREW: ${err.stack || err.message}\n` +
` Recover manually from the backups under ${manifest?.backupDir}. ***\n`
)
}
removeDirIfEmpty(installDir)
rmSync(runDir, { recursive: true, force: true })
}
/** Remove a directory only if it is empty (best-effort; leaves non-empty dirs). */
function removeDirIfEmpty(dir) {
try {
if (existsSync(dir) && readdirSync(dir).length === 0) {
rmSync(dir, { recursive: true, force: true })
}
} catch {
/* leave it in place */
}
}
async function teardown({
app,
created,
userDataDir,
keepInstall,
hadPreexistingInstall,
installedExePath,
runDir
}) {
try {
await closeApp(app)
} catch {
/* already closed */
}
// Kill ONLY processes this harness created: the marker and this run's daemon
// (scoped to the isolated userData). Never touch pre-existing user processes.
killPid(created.markerPid)
for (const pid of resolveScopedDaemon(userDataDir).pids) {
killPid(pid)
}
for (const pid of created.daemonPids) {
killPid(pid)
}
if (keepInstall) {
log('teardown', `--keep-install set; leaving install + ${runDir}`)
return
}
// Only uninstall when the harness fully OWNS the install (no pre-existing
// build). When it overwrote a developer's existing install, leave it in place
// — uninstalling would remove a build we did not put there.
if (hadPreexistingInstall) {
console.log(
'\n*** NOTE: an Orca install existed before this run. It was OVERWRITTEN and the\n' +
' --to version is now installed. Your prior build was NOT restored — reinstall\n' +
' your intended build if needed. Skipping uninstall. ***\n'
)
} else if (installedExePath) {
// Uninstall ONLY the exact directory the harness installed into this run,
// with the explicit default-location opt-in (non-isolated mode legitimately
// installs to the default path and owns it here). Never scan-and-discover.
const uninstalled = silentUninstall(path.dirname(installedExePath), {
allowDefaultLocation: true
})
log('teardown', `uninstalled: ${uninstalled}`)
} else {
log('teardown', 'no install path recorded; skipping uninstall (nothing owned to remove)')
}
rmSync(runDir, { recursive: true, force: true })
}
function killPid(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return
}
try {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
/* already dead */
}
}
function readIntFile(filePath) {
try {
const n = Number(readFileSync(filePath, 'utf8').trim())
return Number.isInteger(n) ? n : null
} catch {
return null
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// Only run the orchestrator when invoked directly, so the module (and
// readDaemonLog) can be imported by tests without kicking off a real install.
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main()
.then((code) => {
// Why: force-exit. A launched Electron app or the window-watch child can
// keep libuv handles open; without this the process lingers to the CI job
// timeout instead of exiting when the run is logically done.
process.exit(code)
})
.catch((err) => {
console.error('[win-update-e2e] FATAL:', err.stack || err.message)
process.exit(1)
})
}