mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
585 lines
20 KiB
JavaScript
585 lines
20 KiB
JavaScript
// Prove packaged Windows daemon/session behavior across real silent N→N+1 NSIS update; see README and windows-terminal-update-survival-plan.md.
|
||
|
||
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)
|
||
}
|
||
|
||
// Snapshot shared registry/shortcuts before isolated install; all later work must reach finally restore.
|
||
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
|
||
}
|
||
|
||
// Always teardown owned app/marker/watch/install and restore isolated shared state; capture driving diagnostics before surfacing FATAL.
|
||
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 install/drive/update/relaunch/assert; assign ctx.session at launch so finally owns partial failures.
|
||
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 base install immediately so any pre-update failure still uninstalls this run’s target.
|
||
ctx.installedExePath = base.exePath
|
||
log('install-base', `installed ${base.exePath} (version ${base.version})`)
|
||
|
||
// Seed only first launch; relaunch must preserve the app’s own saved session.
|
||
const seededRepo = createSeededRepo(path.join(runDir, 'fixture-repo'))
|
||
const seedProfile = buildFreshProfile({ repo: seededRepo })
|
||
|
||
let session = await launchInstalledApp({ exePath: base.exePath, userDataDir, seedProfile })
|
||
ctx.session = session
|
||
// Create first workspace/terminal and clear intercepting overlays.
|
||
await ensureTerminal(session.page, { allowCreate: true })
|
||
await dismissOverlays(session.page)
|
||
const tabIds = await listTabIds(session.page)
|
||
log('sessions', `terminal ready; tab ids: ${tabIds.join(', ')}`)
|
||
|
||
// Prove baseline input while shell is idle, before marker loop owns foreground and prevents command execution.
|
||
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.
|
||
// Allow renderer acknowledgement and teardown; a tree kill invalidates survival evidence.
|
||
await closeApp(session.app, 45_000, { allowForceKill: false })
|
||
const daemonAliveAfterClose = preDaemon.pid != null && isPidAlive(preDaemon.pid)
|
||
log('daemon', `alive after app close: ${daemonAliveAfterClose}`)
|
||
log('marker', `alive after graceful app close: ${isPidAlive(created.markerPid)}`)
|
||
await delay(60_000)
|
||
log(
|
||
'pre-install-control',
|
||
JSON.stringify({
|
||
daemonAlive: preDaemon.pid != null && isPidAlive(preDaemon.pid),
|
||
markerAlive: isPidAlive(created.markerPid)
|
||
})
|
||
)
|
||
|
||
const watchDuration = 120 + opts.soakSeconds
|
||
const watch = startWatch({ baselinePath, outPath: watchOut, durationSec: watchDuration })
|
||
log('watch', `started (duration ${watchDuration}s) -> ${watchOut}`)
|
||
|
||
const toInstaller = resolveInstaller({
|
||
localPath: opts.to,
|
||
releaseTag: opts.toRelease,
|
||
assetPattern: opts.assetPattern
|
||
})
|
||
log('update', `installing ${toInstaller}`)
|
||
const updated = silentInstall(toInstaller, { installDir })
|
||
// Record exact installed directory for teardown; never scan-discover a user install.
|
||
ctx.installedExePath = updated.exePath
|
||
log('update', `installed ${updated.exePath} (version ${updated.version})`)
|
||
log(
|
||
'post-install-before-relaunch',
|
||
JSON.stringify({
|
||
daemonPid: preDaemon.pid,
|
||
daemonAlive: preDaemon.pid == null ? null : isPidAlive(preDaemon.pid),
|
||
markerPid: created.markerPid,
|
||
markerAlive: created.markerPid == null ? null : isPidAlive(created.markerPid)
|
||
})
|
||
)
|
||
|
||
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))
|
||
|
||
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') {
|
||
// Record specific pre-update daemon survival and host exe path whether assertions pass or fail.
|
||
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 heartbeat before the interrupt probe intentionally stops it.
|
||
const markerAliveAfter = isMarkerAlive(runDir)
|
||
log(
|
||
'marker',
|
||
`pid=${readIntFile(path.join(runDir, 'marker.pid'))} aliveAfterUpdate=${markerAliveAfter}`
|
||
)
|
||
// Interrupt the foreground loop before echo commands; otherwise even healthy input cannot execute them.
|
||
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
|
||
}
|
||
}
|
||
|
||
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 only this run’s scoped daemon; prefer PID metadata and cross-check 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,
|
||
// First command-line token distinguishes relocated daemon-host from a fresh install-dir fork.
|
||
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) {
|
||
// Inspect JSONL by event name: fail fatal/invalid-token records, tolerate suppressed native errors and expected version-skew hello rejections.
|
||
// For non-JSON lines, retain raw FATAL matching for crash dumps.
|
||
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 }
|
||
}
|
||
|
||
// Always uninstall the owned isolated target, restore original shared registry/shortcuts (deleting test-created state), then remove empty target/run dirs.
|
||
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) {
|
||
// Surface restore failures with manifest/manual recovery path; never silently skip shared-state recovery.
|
||
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 this run’s marker/scoped daemon, excluding 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
|
||
}
|
||
// Uninstall only fully owned targets; leave a pre-existing developer install that this run updated.
|
||
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 exact recorded directory with explicit default-location opt-in for owned non-isolated installs; never scan-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))
|
||
}
|
||
|
||
// Guard direct invocation so tests importing readDaemonLog cannot install anything.
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||
main()
|
||
.then((code) => {
|
||
// Force exit after teardown because Electron/watch handles can linger until CI timeout.
|
||
process.exit(code)
|
||
})
|
||
.catch((err) => {
|
||
console.error('[win-update-e2e] FATAL:', err.stack || err.message)
|
||
process.exit(1)
|
||
})
|
||
}
|