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

216 lines
12 KiB
Markdown

# win-update-e2e — packaged NSIS update proof harness
**Windows only.** Given two Orca Windows installers (version N and N+1), this
harness performs a real silent update and proves, with machine-checkable
assertions, what happens to the terminal **daemon** and its **sessions** across
the update — and whether any console/terminal window flashes.
It is the Phase 0 "proof harness" deliverable from
[`docs/windows-terminal-update-survival-plan.md`](../../docs/windows-terminal-update-survival-plan.md).
It exists specifically because the July 2026 attempt shipped four broken RCs
without ever installing the packaged artifact (see
[`docs/windows-terminal-update-survival-postmortem.md`](../../docs/windows-terminal-update-survival-postmortem.md),
"Why verification missed every one of these"). Its design refuses to repeat
those verification failures:
- **Window visibility is measured by window enumeration + owner/canary
attribution — never by conhost command-line heuristics.** The post-mortem
proved conhost flags invert with parent console state and `MainWindowHandle`
is `0` for Windows-Terminal-hosted consoles. See `window-enum.ps1`.
- **Interactivity is proven by execution, not by result-shape.** Typed commands
write sentinel **files**; the harness checks the files. A command that "runs
and returns correct output" but flashes a window is still caught, because the
window watch runs independently.
- **The daemon is identified by command-line marker, never by exe name.** With
`ELECTRON_RUN_AS_NODE` the daemon image is `Orca.exe`; a relocated Phase 1
host may be a differently-named copied binary. See `daemon-processes.mjs`.
- **Each run uses an isolated userData dir** so its daemon's socket/token path
is unique and never collides with the many other daemons a dev box or CI
runner can host.
## Usage
```
pnpm win-update-e2e --from <setup.exe> --to <setup.exe> --expect <profile>
# or download release assets via gh (one call each):
pnpm win-update-e2e --from-release v1.4.124-rc.9 --to-release v1.4.125-rc.1 --expect cold-restore
```
Or directly: `node tools/win-update-e2e/run.mjs --from ... --to ... --expect ...`
### Flags
| Flag | Meaning |
| --------------------------------------------- | ----------------------------------------------------------- |
| `--from <path>` / `--to <path>` | Local `orca-windows-setup.exe` for base (N) / update (N+1) |
| `--from-release <tag>` / `--to-release <tag>` | Download the setup asset from a GitHub release tag via `gh` |
| `--expect cold-restore \| survival` | Assertion profile (required) |
| `--install-dir <path>` | Isolated-install mode (see below) — install into `<path>` |
| `--asset-pattern <glob>` | gh asset glob (default `*windows-setup.exe`) |
| `--soak-seconds <n>` | Post-relaunch window-watch soak (default `180`) |
| `--keep-install` | Skip teardown/uninstall for debugging (ignored in isolated) |
### Profiles
- **`cold-restore`** — **today's** behavior and the baseline that must keep
passing against current `main`. The installer's path sweep kills the in-dir
daemon, so: old daemon PID is **dead**, a **fresh** daemon exists, scrollback
is cold-restored (best-effort), a new terminal is interactive, and **zero**
unexpected console/terminal windows appear.
- **`survival`** — the Phase 1 target. Daemon PID **unchanged** across the
update, marker process **still alive**, the pre-update session still
interactive (typed input echoes, Ctrl+C interrupts), and **zero** unexpected
windows.
## Safety
This harness installs, overwrites, and can uninstall a real app. Two guards
protect a developer's machine; a clean CI/VM is unaffected by either:
- **Pre-existing app process → hard refusal.** If an Orca _app_ process (not a
daemon) is already running, the run aborts and prints the offending PIDs. The
harness never kills a process it did not start.
- **Pre-existing install → refusal unless `--allow-existing-install`.** If an
Orca install already exists under `%LOCALAPPDATA%\Programs`, the run refuses,
because installing N then N+1 would silently overwrite that build and leave
the `--to` version behind. Pass `--allow-existing-install` to proceed anyway.
Uninstall behavior at teardown follows ownership:
- **No pre-existing install** (harness fully owns it): teardown silently
uninstalls, unless `--keep-install`.
- **`--allow-existing-install` was used** (an install existed first): teardown
does **not** uninstall — removing a build the harness did not place would be
wrong. It prints a prominent note that the machine now has the `--to` version
and the prior build was not restored.
## Isolated install mode (developer machines)
On a clean CI/VM the harness installs into the default per-user location
(`%LOCALAPPDATA%\Programs\Orca`). A developer's box already has a real Orca there,
and the safety guards above would (correctly) refuse to run. **Isolated mode**
(`--install-dir <path>`) lets the harness run on that box without disturbing the
real install.
**The /D mechanism.** electron-builder's NSIS honors the standard NSIS `/D=<path>`
override for the install *directory* (`node_modules/app-builder-lib/templates/nsis/multiUser.nsh`).
`/D` is special: it must be the **last** argument and **cannot be quoted**, so the
path must be absolute and **spaces-free** (validated by `validateInstallDir`). The
installer's kill-sweep only matches processes under its own `$INSTDIR`, so a
separate directory never touches the real install's app or daemon processes.
**Why registry/shortcut backup-restore exists.** `/D` relocates *files only*.
Regardless of `/D`, the installer writes `InstallLocation` + the uninstall entry to
the **same per-user HKCU keys** as the real install
(`HKCU\Software\<APP_GUID>` and
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<key>`,
`node_modules/app-builder-lib/templates/nsis/include/installer.nsh`) and rewrites the
Start Menu / Desktop shortcuts. Left hijacked, the user's **next real update would
install into the test directory**. So isolated mode, before installing:
1. **Snapshots** the shared state (`registry-shortcut-backup.mjs`): `reg export`s
each existing key to `.reg` files, copies the Orca `*.lnk` shortcuts, and records
a manifest (which keys/shortcuts existed, the pre-run `InstallLocation`).
2. Runs the full install → update → assert proof against the isolated directory.
3. **Always restores** at teardown (a `try/finally` wraps everything after the
snapshot): `reg import`s keys that pre-existed, `reg delete`s keys the test
created, copies shortcuts back / deletes test-created ones, then **re-reads
`InstallLocation` and verifies** it matches the snapshot. On mismatch it prints a
loud block with the exact manual `reg import` command to recover. Isolated
teardown **always** uninstalls the test install (the harness owns the directory)
and removes the directory if empty — `--keep-install` is ignored.
**Residual risk.** The backup/restore covers `InstallLocation`, the uninstall entry,
and the Orca shortcuts — the state that steers a future update and the user-visible
launchers. It does **not** attempt to snapshot auto-update state files under the real
install's `userData` (the harness uses an isolated `userData` throughout, so it never
writes there), and it cannot restore state if the machine loses power mid-teardown
(re-run with a valid `--install-dir` to let restore complete, or run the printed
`reg import` by hand). The `.reg` backups live under the run's temp dir until a
successful teardown removes it.
**Example.**
```
pnpm win-update-e2e \
--from-release v1.4.124-rc.9 --to-release v1.4.125-rc.1 \
--expect cold-restore --install-dir C:\OrcaE2E
```
Read-only, touches nothing — print what isolated mode would snapshot on this machine:
```
node tools/win-update-e2e/registry-shortcut-backup.mjs
```
## What it does
1. **Preflight** — assert win32; warn if elevated; **refuse** to run if a
pre-existing Orca _app_ process (not a daemon) is running that the harness
did not start (it is printed and the run aborts — the harness never kills a
user's processes); snapshot the baseline set of visible top-level windows.
2. **Install N** silently (`<setup.exe> /S`) and locate `Orca.exe`.
3. **Launch** the installed app (Playwright `_electron`, isolated userData),
create ≥2 terminals, start a **marker** in one: a `powershell` loop that sets
a unique window-title **canary**, records its PID, and heartbeats a file.
4. **Record** the daemon PID (scoped pid-file + live-process scan), marker PID,
and session tab ids.
5. **Close** the app normally; verify the detached daemon is still alive.
6. **Start the window watch** — a background PowerShell loop polling visible
top-level windows every 500ms, diffing against baseline, recording every new
window (and title change) to a JSONL log through the update and soak.
7. **Install N+1** silently (the update).
8. **Relaunch** the app.
9. **Assert** per profile, then print a PASS/FAIL/INFO evidence table.
10. **Teardown** (unless `--keep-install`) — close app, kill only harness-created
processes, silent-uninstall.
Exit code is `0` when every non-informational assertion passes, else `1`
(`2` for a CLI usage error).
## Standalone instrument self-tests (no installers needed)
Each probe module runs on its own so the harness's own instruments are testable:
```
# Opens a real transient console window and asserts the watch catches it:
node tools/win-update-e2e/window-watch.mjs --selftest
# Read-only: list daemon processes + PID files on this machine:
node tools/win-update-e2e/daemon-processes.mjs [--user-data <dir>] [--scope <substr>]
# Emit the current visible-window snapshot as JSON:
powershell -File tools/win-update-e2e/window-enum.ps1
```
## Files
| File | Responsibility |
| -------------------------- | ----------------------------------------------------------------------------- |
| `run.mjs` | Orchestrator + CLI entry |
| `cli-args.mjs` | Argument parsing / validation |
| `preflight.mjs` | win32/elevation checks, pre-existing-app refusal, baseline snapshot |
| `installer-steps.mjs` | Silent install/update/uninstall, exe discovery, gh download |
| `registry-shortcut-backup.mjs` | Isolated mode: snapshot/restore the shared HKCU keys + Orca shortcuts |
| `app-driver.mjs` | Playwright Electron launch + terminal driving (production-safe DOM selectors) |
| `interactivity-probes.mjs` | Sentinel-file echo / heartbeat / Ctrl+C probes |
| `daemon-processes.mjs` | Daemon PID discovery (command-line marker + pid file), scoped |
| `window-enum.ps1` | Shared visible-top-level-window enumerator (P/Invoke `EnumWindows`) |
| `window-watch.ps1` | Background baseline-diff watch loop → JSONL |
| `window-watch.mjs` | Node wrapper: start/stop watch, `--selftest`, baseline capture |
| `assertions.mjs` | Window-event classification + profile PASS/FAIL table |
| `platform-guard.mjs` | `assertWin32`, elevation detection |
| `powershell-runner.mjs` | Windows PowerShell 5.1 spawn helpers |
## Known limitations
- **Scrollback fidelity is best-effort.** A production build renders the
terminal with WebGL, so xterm text is not reliably in the DOM and the e2e
`SerializeAddon` is not exposed. When text cannot be read the check reports
`INFO` (unknown), never a false `FAIL`.
- **Daemon file log** does not exist yet in packaged builds (the fork's stdio is
suppressed). The "daemon log free of ERROR lines" assertion is `INFO` until
Phase 0 daemon logging lands, then it reads `<userData>/logs/daemon.log`.
- The harness assumes the packaged main honors `ORCA_E2E_USER_DATA_DIR` to
relocate userData; verify this against a real packaged build.