187 lines
6.9 KiB
JavaScript
187 lines
6.9 KiB
JavaScript
// Profile assertions and the final PASS/FAIL evidence table.
|
|
//
|
|
// Two profiles:
|
|
// cold-restore — TODAY's behavior. The installer's path sweep kills the
|
|
// in-dir daemon, so the old daemon PID must be DEAD, a fresh daemon must
|
|
// exist, scrollback is cold-restored (best-effort), a new terminal is
|
|
// interactive, and NO unexpected console/terminal windows appear.
|
|
// survival — Phase 1 target. The daemon PID is UNCHANGED across the update,
|
|
// the marker process is still alive, the pre-update session is still
|
|
// interactive (echo + Ctrl+C), and NO unexpected windows appear.
|
|
|
|
const CONSOLE_HOST_PROCESSES = new Set([
|
|
'powershell',
|
|
'pwsh',
|
|
'cmd',
|
|
'conhost',
|
|
'windowsterminal',
|
|
'openconsole'
|
|
])
|
|
|
|
// The app's own windows are expected on relaunch and never count as a flash.
|
|
const APP_OWNER_PROCESSES = new Set(['orca', 'electron'])
|
|
|
|
/**
|
|
* Split window-watch events into unexpected flashes vs benign. Unexpected =
|
|
* (a) any window whose title contains the run's canary — a real flash of our
|
|
* marker child, which must never get its own window — or (b) any new
|
|
* console/terminal-host window that is not the app itself. Attribution is by
|
|
* title + owner process only (never conhost command-line heuristics).
|
|
*/
|
|
export function classifyWindowEvents(events, { canary }) {
|
|
const unexpected = []
|
|
for (const event of events) {
|
|
const title = typeof event.title === 'string' ? event.title : ''
|
|
const owner = (event.processName ?? '').toLowerCase()
|
|
const canaryHit = canary && title.includes(canary)
|
|
const consoleHit = CONSOLE_HOST_PROCESSES.has(owner) && !APP_OWNER_PROCESSES.has(owner)
|
|
if (canaryHit || consoleHit) {
|
|
unexpected.push({ ...event, reason: canaryHit ? 'canary-title' : 'console-host' })
|
|
}
|
|
}
|
|
return { unexpected }
|
|
}
|
|
|
|
function assertion(name, pass, expected, actual, detail = '') {
|
|
return { name, pass, expected, actual, detail }
|
|
}
|
|
|
|
/** Build the ordered assertion list for the run's profile. */
|
|
export function buildAssertions(ctx) {
|
|
const { unexpected } = classifyWindowEvents(ctx.watchEvents ?? [], { canary: ctx.canary })
|
|
const windowAssertion = assertion(
|
|
'zero unexpected console/terminal windows',
|
|
unexpected.length === 0,
|
|
'0 windows',
|
|
`${unexpected.length} windows`,
|
|
unexpected.map((u) => `${u.processName}:"${u.title}" (${u.reason})`).join('; ')
|
|
)
|
|
|
|
const common = [windowAssertion, daemonLogAssertion(ctx)]
|
|
|
|
return ctx.profile === 'survival'
|
|
? [...survivalAssertions(ctx), ...common]
|
|
: [...coldRestoreAssertions(ctx), ...common]
|
|
}
|
|
|
|
function survivalAssertions(ctx) {
|
|
const samePid =
|
|
ctx.preDaemonPid != null && ctx.preDaemonPid === ctx.postDaemonPid && ctx.postDaemonAlive
|
|
return [
|
|
assertion(
|
|
'daemon PID unchanged across update',
|
|
samePid,
|
|
`pid ${ctx.preDaemonPid} still alive`,
|
|
`post pid ${ctx.postDaemonPid} (alive: ${ctx.postDaemonAlive})`
|
|
),
|
|
assertion(
|
|
'marker process still alive',
|
|
Boolean(ctx.markerAliveAfter),
|
|
`marker pid ${ctx.markerPid} alive`,
|
|
String(ctx.markerAliveAfter)
|
|
),
|
|
assertion(
|
|
'pre-update session streams (heartbeat advanced)',
|
|
Boolean(ctx.heartbeatAdvancedAfterUpdate),
|
|
'heartbeat mtime advanced post-update',
|
|
String(ctx.heartbeatAdvancedAfterUpdate)
|
|
),
|
|
assertion(
|
|
'typed input echoes in pre-update session',
|
|
Boolean(ctx.echoObserved),
|
|
'echo sentinel file written',
|
|
String(ctx.echoObserved)
|
|
),
|
|
assertion(
|
|
'Ctrl+C interrupts marker loop',
|
|
Boolean(ctx.ctrlCInterrupted),
|
|
'heartbeat stopped + post-interrupt sentinel written',
|
|
String(ctx.ctrlCInterrupted)
|
|
)
|
|
]
|
|
}
|
|
|
|
function coldRestoreAssertions(ctx) {
|
|
const freshDaemon =
|
|
ctx.postDaemonPid != null && ctx.postDaemonPid !== ctx.preDaemonPid && ctx.postDaemonAlive
|
|
return [
|
|
assertion(
|
|
'old daemon PID is dead after update',
|
|
ctx.preDaemonAliveAfter === false,
|
|
`pid ${ctx.preDaemonPid} dead`,
|
|
`alive: ${ctx.preDaemonAliveAfter}`
|
|
),
|
|
assertion(
|
|
'fresh daemon exists after relaunch',
|
|
freshDaemon,
|
|
'new daemon pid, alive',
|
|
`post pid ${ctx.postDaemonPid} (alive: ${ctx.postDaemonAlive})`
|
|
),
|
|
// Best-effort: WebGL renderer may hide buffer text from DOM scraping, so a
|
|
// null result is reported as informational (pass=null), not a failure.
|
|
assertion(
|
|
'previous terminals show restored scrollback (best-effort)',
|
|
ctx.scrollbackRestored === null ? null : ctx.scrollbackRestored,
|
|
'prior output text present after restore',
|
|
ctx.scrollbackRestored === null ? 'unknown (renderer opaque)' : String(ctx.scrollbackRestored)
|
|
),
|
|
assertion(
|
|
'new terminal is interactive (typed input echoes)',
|
|
Boolean(ctx.echoObserved),
|
|
'echo sentinel file written',
|
|
String(ctx.echoObserved)
|
|
),
|
|
assertion(
|
|
'Ctrl+C kills a sleep loop in new terminal',
|
|
Boolean(ctx.ctrlCInterrupted),
|
|
'loop interrupted + post-interrupt sentinel written',
|
|
String(ctx.ctrlCInterrupted)
|
|
)
|
|
]
|
|
}
|
|
|
|
function daemonLogAssertion(ctx) {
|
|
// The daemon has no file log today (stdio is suppressed in packaged forks).
|
|
// Treat "no log present" as informational; when a log IS present (Phase 0
|
|
// observability), assert it is free of ERROR lines.
|
|
if (!ctx.daemonLog) {
|
|
return assertion(
|
|
'daemon log free of fatal records',
|
|
null,
|
|
'no fatal/invalid-token records',
|
|
'no daemon log present (informational)'
|
|
)
|
|
}
|
|
// Only genuinely-bad records count (fatal uncaught exceptions, invalid-token
|
|
// hello rejections). Benign suppressed native-PTY exceptions are reported as
|
|
// context but never affect pass/fail.
|
|
const errorLines = ctx.daemonLog.errorLines ?? []
|
|
const suppressed = ctx.daemonLog.suppressedCount ?? 0
|
|
const suppressedNote = suppressed > 0 ? ` (${suppressed} benign suppressed, ignored)` : ''
|
|
return assertion(
|
|
'daemon log free of fatal records',
|
|
errorLines.length === 0,
|
|
'no fatal/invalid-token records',
|
|
`${errorLines.length} fatal record(s)${suppressedNote}`,
|
|
errorLines.slice(0, 3).join(' | ')
|
|
)
|
|
}
|
|
|
|
/** True only if every non-informational assertion passed. */
|
|
export function allPassed(assertions) {
|
|
return assertions.every((a) => a.pass === true || a.pass === null)
|
|
}
|
|
|
|
/** Render the assertion list as an aligned PASS/FAIL/INFO table. */
|
|
export function renderTable(assertions) {
|
|
const symbol = (pass) => (pass === true ? 'PASS' : pass === false ? 'FAIL' : 'INFO')
|
|
const nameWidth = Math.max(...assertions.map((a) => a.name.length), 10)
|
|
const lines = assertions.map((a) => {
|
|
const detail = a.detail ? ` — ${a.detail}` : ''
|
|
return ` [${symbol(a.pass)}] ${a.name.padEnd(nameWidth)} expected: ${a.expected}; actual: ${a.actual}${detail}`
|
|
})
|
|
const failed = assertions.filter((a) => a.pass === false).length
|
|
const header = `\n===== win-update-e2e assertions (${failed === 0 ? 'ALL PASS' : `${failed} FAILED`}) =====`
|
|
return [header, ...lines, ''].join('\n')
|
|
}
|