Can an agent see the app?
Can an agent see the app?
Delegation 06 — a feasibility spike.
Phase 1.8.7 addendum (2026-09-04). This report is left as written: it is the record of what was true when the spike ran, and its three caveats are the reason the phase that followed had the shape it did. What has changed since:
- The route list is no longer a copy.
src/routes.tsis the one table;App.tsxrenders from it ande2e/routes.tsimports it, andsrc/__tests__/routes.test.tsxfails when they disagree. The sweep also grew from 14 routes to 24 — the six Settings tabs and four About tabs had never been visited, because the suite reached/settings, landed on the index redirect, and never saw the other five.- The two third-party surfaces are asserted, positively.
e2e/third-party.spec.tsscrolls each of the seventeen YouTube embeds (this report said six; it counted the ones that load eagerly) into view, evaluates inside the cross-origin frame, and requires a mounted player, no.ytp-error, and a real video title — because a removed video answers HTTP 200 and a status check would call it healthy. The blog<webview>is watched from the main process throughweb-contents-created. A machine that cannot reach either origin skips, printing what it did not verify.- The fuses are read, not trusted.
e2e/fixtures/electron-fuses.tsparses the fuse wire out of the built binary andpackaged.spec.tsfails ifRunAsNodeorEnableNodeCliInspectArgumentsis disabled;src/__tests__/electronFuses.test.tsfails in CI if the build block ever asks for them off, which is the half that a local-only packaged spec could not cover. The parse was checked againstnpx @electron/fuses read, and flipping the byte by hand was confirmed to make the packaged app fail to launch at all.Still open, and named in the 1.8.7 handback rather than here: Windows, Intel macOS, a signed or quarantined app, the packaged app on CI, and any window-manager behaviour under xvfb. Branched from
8fa38ba(phase 1.8.5’s four PRs,#51–#54), which is past the6d38f8dthe brief was written against; the brief said to say so, and this is that.
resmon is an Electron shell around a Python backend, and nothing had ever
launched it under automation. 154 renderer tests across 21 suites, all jsdom,
none of which renders the real app in a real browser engine. electron/main.ts
— 708 lines carrying every v1.8.3 interface fix — has no test of any kind. The
release workflow builds four installers and verifies the Python interpreter
inside them without ever opening a window. The maintainer reviews the interface
by hand, from a phone.
This spike establishes whether the conventional route — Playwright driving the app’s own Electron — works against this app, and what it costs. It does not design the verification layer; that is phase 1.8.7’s job.
Verdict
Viable, with three caveats, all of them cheap.
Everything the brief asked about works, on the first serious attempt, on both a
checkout and the packaged .app. The session-5 failure does not reproduce. The
caveats are:
- The route list is a copy.
e2e/routes.tsis copied by hand fromApp.tsx, because making the route table the single source of truth is a change tosrc/**that this spike was not permitted to make. Until 1.8.7 does it, a route added toApp.tsxand not toroutes.tsis unswept and nothing fails. - Assertions are scoped to resmon’s own origins. The app embeds six
youtube-nocookie.comiframes and a GitHub Pages<webview>, and both produce console errors and aborted requests that no change to this repository can fix. Scoping the assertions to127.0.0.1is what makes the suite a signal rather than a coin-flip — and it means a broken embed is invisible to the smoke suite. - A CI runner under xvfb has no window manager, so anything window-level —
maximize, the
swipegesture, focus, stacking — is invisible there even though every route renders correctly. And this is one machine and one runner: macOS 15 on arm64 plusubuntu-24.04. Windows, Intel macOS, a signed app and a Gatekeeper-quarantined app are all untested — see What could not be established.
The costs: 18.1 MiB of node_modules, zero bytes in any installer, and
~11 s for a 14-route pass locally.
The environment every number below was measured in
| Host | macOS (Darwin 25.3.0), arm64 |
| Node / npm | v25.2.1 / 11.6.2 |
| Python (backend) | 3.11.5, the checkout’s .venv |
| Electron | 41.10.6 (npx electron --version) |
| Chromium inside it | 146.0.7680.216 |
| Node inside it | 24.18.0 |
| Playwright | @playwright/test@1.62.1, pinned exact |
| CI | ubuntu-latest, Node 20, Python 3.11 |
@playwright/test@1.62.1 declares "engines": {"node": ">=20"}. CI ran Node
20 when these numbers were taken — exactly at the floor, which is worth knowing
before anyone lowers it. It no longer does: the trim PR moved all six
node-version lines to 22 and package.json now declares
"engines": {"node": ">=22.13"}. The table above is left as it was measured.
Q1 — does _electron.launch() start this app?
Yes. First attempt, no workarounds.
const app = await electron.launch({
args: ['.', `--user-data-dir=${…}`],
cwd: 'resmon_scripts/frontend',
env: { RESMON_STATE_DIR, RESMON_DB_PATH, RESMON_REPORTS_DIR,
RESMON_PORT_FILE, RESMON_PYTHON, RESMON_E2E: '1', … },
});
const win = await app.firstWindow({ timeout: 180_000 });
The very first probe printed:
VERSIONS {"electron":"41.10.6","node":"24.18.0","chrome":"146.0.7680.216"}
URL http://127.0.0.1:58714/index.html
TITLE resmon
Playwright’s Electron support is documented as experimental and its
supported-version list predates Electron 41. That turned out not to matter:
_electron.launch() runs the Electron binary already in node_modules, so
there is no version negotiation to fail.
It downloads nothing. npm install @playwright/test added no browser to
~/Library/Caches/ms-playwright — the newest directory there predates this work
by a month. _electron.launch() uses the app’s own Electron, and npx
playwright install is never run. The one thing CI does install is
playwright install-deps chromium, which is the system libraries Chromium
links against — needed for the app’s own Electron on a bare runner, not a
browser download.
What the launch environment has to say
main.ts reads exactly two environment variables (RESMON_STATE_DIR,
RESMON_PYTHON) and the backend reads three more. Together they are the whole
isolation story:
| Variable | Why |
|---|---|
RESMON_STATE_DIR |
A fresh temp directory, so tryAttachToDaemon() finds no lock file and the app spawns its own backend instead of attaching to the launchd daemon on 8742. |
RESMON_DB_PATH, RESMON_REPORTS_DIR, RESMON_PORT_FILE |
In a checkout main.ts sets none of these — it only sets them when app.isPackaged. Without them the spawned backend writes resmon.db into the repository root. |
RESMON_PYTHON |
The checkout’s .venv; falls back to python3 on CI. |
--user-data-dir |
Not resmon’s — Chromium’s. See the finding below. |
No pinned backend port is needed, and that is worth recording. main.ts
picks a free port and hands it to the renderer through the preload’s
--backend-port= argument, so the suite reads it back out with
window.resmonAPI.getBackendPort(). That is one fewer RESMON_E2E branch than
the brief anticipated. Every spec asserts the port it found is not 8742.
Finding: RESMON_STATE_DIR does not isolate Electron
The first full run returned a window whose bounds were 1440×900 and whose renderer reported a viewport of 1200×723.
MAIN {"bounds":{"width":1440,"height":900},"maximized":false,"zoom":1.2,"scale":2}
RENDERER {"inner":[1200,723],"outer":[1440,900],"dpr":2.4000000953674316}
The zoom factor was 1.2, and it came from outside the test:
APP {"userData":"/Users/…/Library/Application Support/resmon","isPackaged":false}
RESMON_STATE_DIR isolates the backend’s state — database, reports, daemon
lock. It does nothing about Chromium’s own profile, which app.getPath(
'userData') puts in the real ~/Library/Application Support/resmon: the
directory the installed app uses. The suite was reading a per-origin zoom level
somebody had set in a real session, and writing into that profile as it went.
Passing Chromium’s --user-data-dir switch in args fixes it, and costs no app
change at all — the switch is handled before any of main.ts runs:
MAIN {"bounds":{"width":1440,"height":900},"maximized":false,"zoom":1}
RENDERER {"inner":[1440,868],"outer":[1440,900],"dpr":2}
APP {"userData":"/private/var/folders/…/T/resmon-e2e-rO2ynO/electron-user-data"}
For 1.8.7: a verification layer that isolates only RESMON_STATE_DIR reads
the developer’s persisted renderer state and writes to their profile. Both halves
matter — a screenshot taken at an inherited zoom is not evidence of anything.
Q2 — all fourteen routes, screenshotted?
Yes. 14 of 14. The denominator is ROUTES in e2e/routes.ts, copied by
hand from App.tsx’s <Routes> block at 8fa38ba, and the copy is noted in
that file and in caveat 1 above.
$ npm run e2e:smoke
Running 14 tests using 1 worker
✓ 1 route smoke › Dashboard (/) loads with no console errors (1.6s)
✓ 2 route smoke › Deep Dive (/dive) … (721ms)
…
✓ 14 route smoke › About resmon (/about-resmon) … (629ms)
14 passed (12.5s)
Screenshots land in e2e/screenshots/, one per route, at a window of
1440×900 with a 1440×868 viewport (the 32 px is the title bar) on a
device-pixel-ratio-2 display, so each PNG is 2880×1736. The size is fixed by
RESMON_E2E precisely so that caption means something; see Q7.
Each spec asserts three things:
.app-mainis visible — the shell painted.main.main-contentis non-empty. This is the assertion that matters and it was not the first one written. The first version asserted thatbodyhad text, which passes on a route that renders nothing at all, because the sidebar’s own labels satisfy it.Layout/MainContent.tsxrenders<main className="main-content">{children}</main>and nothing else, so it is empty exactly when<Routes>matched nothing. Q5’sbreak_routedemonstration below is what proved the difference.- No console error and no failed request from resmon’s own origins — see Q3 for what that scoping is and what it costs.
How the corpus was seeded
Through the backend’s own API, from the renderer’s own origin: a Deep Dive
POST /api/search/dive against arXiv. prepare_execution() inserts the
execution row before the worker thread touches the network, so this seeds an
execution whether or not the machine can reach arXiv — the spec asserts on the
tab activating, never on papers coming back. Nothing writes to SQLite directly;
the schema is not a spike’s to write to.
Twelve of the fourteen routes render fine against an empty database, which is itself worth knowing: empty states are real states and they all paint.
Q3 — console errors and failed requests, per page?
Yes, both, and a third channel the brief did not name.
| Channel | Provoked how | Captured |
|---|---|---|
page.on('console') |
console.error('e2e-provoked-console-error') |
{"route":"/analytics","text":"e2e-provoked-console-error","location":":1:12"} |
page.on('requestfailed') |
page.route('**/api/analytics/**', r => r.abort('failed')) |
{"route":"/analytics","url":"http://127.0.0.1:59626/api/analytics/overview","failure":"net::ERR_FAILED"} |
page.on('pageerror') |
setTimeout(() => { throw new Error(…) }) |
{"route":"/watchdog","text":"[pageerror] e2e-provoked-uncaught","location":"uncaught"} |
pageerror is not optional. An uncaught exception in the renderer — a React
render crash, the thing a smoke suite most needs to catch — is not a console
event. Without the third listener the console collector stays empty and the
suite stays green.
One thing the aborted-request provocation established by accident: resmon
handles a failed Analytics fetch without logging anything. The page degrades
quietly. So requestfailed is the only channel that sees a broken backend call,
and a verification layer that watches only the console would miss every one.
Finding: the assertions had to be scoped, and it cost something
Running smoke.spec.ts alone was green 5 times out of 5. Running the whole
suite failed 3 times out of 3, and then 5 times out of 5, for two
different reasons — neither of them resmon’s fault, and both of them worth
writing down because 1.8.7 will hit them again.
First: third-party requests. About resmon → Tutorials embeds six
youtube-nocookie.com iframes. Navigating away cancels whatever they still have
in flight, and Chromium reports each as net::ERR_ABORTED, arriving after the
next route is current and filed against whichever route that is:
Error: failed requests on /:
[ { "route": "/", "url": "https://www.youtube-nocookie.com/embed/C9F5H_-mzzY?…",
"failure": "net::ERR_ABORTED" }, … ]
Between 2 and 6 of them per run, on the Dashboard, which embeds no video at all.
Second: third-party console errors. YouTube’s own player bundle logs one:
"Permissions policy violation: compute-pressure is not allowed in this document."
at https://www.youtube-nocookie.com/s/player/9470c977/player_embed_es6.vflset/en_US/base.js:5405
1 run in 5, from a player build number that changes between runs.
Both assertions are now scoped by isOwnOrigin() — resmon’s own origins are
http://127.0.0.1:* (the renderer’s static server and the backend) plus
messages with no source URL, which is what a page.evaluate raises. Third-party
events are printed, not dropped, so a person reading the log still sees them.
What that scoping cannot see, stated plainly: a broken YouTube embed, or a
broken blog <webview>, no longer fails the suite. Those are two real
user-visible surfaces on the About resmon page. If 1.8.7 wants them covered, it
needs a check that asserts the embed loaded rather than one that asserts
nothing failed.
Finding: worker-scoped collectors leak across spec files
observability.spec.ts deliberately provokes a console error on /analytics.
It sorts before smoke.spec.ts, and with workers: 1 Playwright reuses one
worker across files — so the Analytics smoke test inherited the provocation and
went red on 5 of 5 full-suite runs while the smoke file alone was green on 5 of
- An auto-use fixture empties the collectors before each test. What that still cannot fix, and what the fixture header says: a late event from the previous route inside the same test is filed against the wrong route.
Q4 — does the session-5 failure reproduce?
No. The Search-record tab activates. This was the most important question this spike could answer and the answer is unambiguous.
Session 5 drove the renderer with headless Chrome against a served build and reported that the report viewer’s Search record tab “would not activate under headless click automation”. Under Playwright driving the app’s own Electron, on a real display, with a real trusted input event at the element’s hit point:
Q4 DOM AFTER CLICK {
"classesBefore":"tab-btn ",
"classesAfter":"tab-btn tab-active",
"activeLabel":"Search record",
"recordPanelCount":1,
"finalStatus":"completed"
}
The class transitions, .tab-bar .tab-btn.tab-active resolves to exactly one
element and its label is Search record, and .search-record — the panel the
tab selects, which fetches /api/executions/{id}/search-record on mount —
renders. The screenshot is e2e/screenshots/15-search-record-tab.png.
The click is locator.click(), dispatched through Chromium’s input pipeline,
not element.click() and not a synthetic MouseEvent. That distinction is the
whole question: a React onClick fires for all three, but an element that is
covered, scrolled out of view or zero-size fails only the real one.
What changed since session 5 is not knowable from here. Three things differ at once — Playwright instead of raw CDP, the app’s own Electron instead of a separate headless Chrome, and a real window instead of headless — and the session-5 attempt is not in the repository to re-run. The honest statement is that the failure does not reproduce in this configuration, not that a specific one of those three was the cause.
Q5 — can the three IPC channels be stubbed?
Yes, from the main process, and nothing reaches the operating system.
electronApp.evaluate() runs in the main process with the electron module in
scope, so ipcMain.removeHandler() followed by ipcMain.handle() replaces
the real handler rather than intercepting it — dialog.showOpenDialog is never
reached at all.
Q5 IPC RESULTS {"chosen":"/tmp/e2e-chosen-directory","opened":"","revealed":true}
Q5 GUARD COUNTS {
"stubbed":{"chooseDirectory":1,"openPath":1,"revealPath":1},
"escaped":{"showOpenDialog":0,"showMessageBox":0,"openExternal":0,
"openPath":0,"showItemInFolder":0}
}
The escaped row is the point. “No native dialog opened” is a measurement,
not an inference: every OS-facing call main.ts makes is replaced with a
counter first. The five are the complete set, taken from
grep -n 'dialog\.\|shell\.' electron/main.ts — dialog.showOpenDialog,
dialog.showMessageBox (the auto-updater’s prompt), shell.openExternal,
shell.openPath, shell.showItemInFolder.
A full pass over all fourteen routes with the guards installed:
P4 GUARD COUNTS AFTER FULL PASS {
"stubbed":{"chooseDirectory":0,"openPath":0,"revealPath":0},
"escaped":{"showOpenDialog":0,"showMessageBox":0,"openExternal":0,
"openPath":0,"showItemInFolder":0}
}
Merely visiting every page opens nothing — which is expected, since all three channels are behind buttons. The measurement that matters for 1.8.7 is that when the buttons are pressed, the stubs answer and the OS never hears about it.
What this cannot see: the guards are installed after the app is up, so
anything a dialog might do during startup is outside them. Nothing in main.ts
does, but that is read from the code rather than measured.
Q6 — CI under xvfb, and the packaged app
The packaged app: yes
Q6 PACKAGED APP {"isPackaged":true,"version":"1.8.4","electron":"41.10.6",
"resources":"…/release/mac-arm64/resmon.app/Contents/Resources"}
Q6 PACKAGED BACKEND PORT 61145
Q6 PACKAGED EMPTY ROUTES []
All fourteen routes render from the shipped bundle, driven through
executablePath: resmon.app/Contents/MacOS/resmon, with the app’s own bundled
Python rather than the checkout’s venv (RESMON_PYTHON is deleted from the
packaged launch env on purpose). RESMON_STATE_DIR still redirects the state,
and RESMON_DB_PATH / RESMON_REPORTS_DIR are honoured because main.ts sets
them with ||.
The fuse: EnableNodeCliInspectArguments is Enabled
Playwright drives the main process over the Node inspector, so this fuse decides
whether electronApp.evaluate() works on a packaged app at all. Electron
Forge’s default template disables it. electron-builder 26.15.3 does not flip
fuses, and resmon’s build block asks for none:
$ npx @electron/fuses read --app release/mac-arm64/resmon.app
Fuse Version: v1
RunAsNode is Enabled
EnableCookieEncryption is Disabled
EnableNodeOptionsEnvironmentVariable is Enabled
EnableNodeCliInspectArguments is Enabled
EnableEmbeddedAsarIntegrityValidation is Disabled
OnlyLoadAppFromAsar is Disabled
LoadBrowserProcessSpecificV8Snapshot is Disabled
GrantFileProtocolExtraPrivileges is Enabled
WasmTrapHandlers is Enabled
This is a fact with a shelf life. RunAsNode and
EnableNodeCliInspectArguments being on is what makes the packaged app
driveable — and both are also the fuses a hardening pass would want to turn
off. If resmon ever flips them, packaged-app verification stops working the
same day, silently. Worth a note wherever fuses are eventually configured.
Playwright ships in no installer
build.files is ["dist/**/*", "package.json"], and electron-builder adds
production dependencies only. @playwright/test is a devDependency, so:
$ npx @electron/asar list app.asar | wc -l → 723
$ npx @electron/asar list app.asar | grep -ci playwright → 0
0 of 723 entries. DMG sizes: resmon-1.8.3-arm64.dmg 221,086,731 B
(built 2026-09-02, before this branch) → resmon-1.8.4-arm64.dmg
221,100,923 B (built on this branch), a difference of 14,192 bytes
(+0.006%). That comparison spans a version bump and phase 1.8.5’s whole backend,
so it is not a clean control — the asar listing is the real evidence and the
files glob is the reason.
node_modules grew 553 MB → 572 MB. The three packages are @playwright
60 KB, playwright 5.0 MB, playwright-core 13 MB — 18.1 MiB.
xvfb on ubuntu-latest: see the CI run
.github/workflows/ui-smoke.yml runs the suite under
xvfb-run --auto-servernum --server-args='-screen 0 1600x1000x24'. The window
is fixed at 1440×900 by RESMON_E2E; the virtual display has to be larger than
the window, which is the only reason the screen size is stated.
The job installs Electron’s system libraries with sudo npx playwright
install-deps chromium — used here for the app’s own Electron, not to fetch a
browser — and xvfb from apt. It installs the backend’s Python requirements
too, because the app under test spawns the real FastAPI backend; without that
the window opens and every page reads Backend: Offline.
Yes — the app launches and every route is walked under xvfb, on
ubuntu-24.04 (runner image 20260831.293), Node 20 (as measured; CI runs Node 22 since
the trim PR).
| Run | What it shows | Result |
|---|---|---|
| 33821624928 | The first run, before the P6 correction below | 19 passed, 1 skipped, 1 failed in 41.5 s |
| 33821961524 | The suite as it stands | 20 passed, 1 skipped, 34.7 s |
| 33822159270 | break_route=/no-such-page — the P5 demonstration below |
20 passed, 1 failed |
The skip is packaged.spec.ts, by design. The first run’s failure was this
spike’s own spec, not the app, and it is a finding worth more than the green
tick.
Finding: xvfb has no window manager, so maximize() does nothing
default-behaviour.spec.ts asserted that with RESMON_E2E unset the window
maximizes. On CI it came back:
P6 RESMON_E2E UNSET {"bounds":{"width":1280,"height":820},"maximized":false,
"workArea":{"width":1600,"height":1000},…}
P6 RESMON_E2E SET {"bounds":{"width":1440,"height":900},"maximized":false,
"workArea":{"width":1600,"height":1000},…}
xvfb-run starts a bare X server with no window manager, and
BrowserWindow.maximize() is a request that a window manager honours. With none
present the call is a no-op: the window stayed at the constructor’s 1280×820 on
a 1600×1000 virtual screen. That is not the app misbehaving, and no
RESMON_E2E branch caused it.
The spec now asserts the portable property — with the flag unset the app does not take the E2E size, and takes whichever pre-flag size the display allows — and logs which arm held, so a run says out loud whether it verified the maximize or not:
P6 UNSET WINDOW OUTCOME maximized to the work area (a window manager honoured it) ← macOS
P6 UNSET WINDOW OUTCOME left at the 1280x820 default (no window manager — bare X,
e.g. xvfb); the maximize-on-open behaviour is NOT verified
by this run ← CI
For 1.8.7, this generalises past one assertion. Anything that depends on a
window manager is invisible on a bare-X runner: maximize and restore, the
swipe gesture main.ts binds for history navigation, window-level focus,
multi-window stacking, and anything about the title bar. A CI job under xvfb
verifies the renderer thoroughly and the window barely. If window behaviour
matters, the runner needs a light window manager alongside xvfb, and that is a
decision rather than an oversight.
P5 — the job fails when a route fails to load, demonstrated
ui-smoke.yml takes a break_route workflow_dispatch input which sets
RESMON_E2E_BREAK_ROUTE; routes.ts then appends a route App.tsx does not
declare. React Router renders nothing for it while the sidebar and header paint
normally — the exact failure a smoke suite exists to catch.
Run 33822159270,
dispatched with break_route=/no-such-page:
✘ 22 route smoke › Deliberately broken route (/no-such-page) loads with no console errors (622ms)
Error: route /no-such-page rendered an empty <main class="main-content">
Expected: > 0
Received: 0
1 failed, 20 passed (36.0s)
The job exits non-zero. It is a workflow_dispatch input rather than a commit
on purpose: a demonstration that needs a commit to reproduce stops being
reproducible the moment the commit is reverted. Anyone can re-run it, from the
Actions tab or with:
gh workflow run ui-smoke.yml --repo ryanjosephkamp/resmon \
--ref spike/ui-verification -f break_route=/no-such-page
The same input works locally: RESMON_E2E_BREAK_ROUTE=/no-such-page npm run e2e:smoke.
And the mutation this demonstration actually performed. With the first
version of the assertion — body has text, rather than main.main-content has
text — the broken route passed, because the sidebar’s own labels satisfied
it. The break_route input did not merely confirm a working guard; it is what
found that the guard was not one.
Q7 — what the app needed, and the RESMON_E2E guard
Three hunks in electron/main.ts, all behind one guard, all test determinism.
None of them is behaviour a user could want, and none changes what the app
shows.
| Hunk | Where | Why |
|---|---|---|
| 1 | createWindow(), the BrowserWindow constructor |
A fixed window size from RESMON_E2E_WIDTH / RESMON_E2E_HEIGHT, defaulting to 1440×900. A maximized window is a different size on every machine and on CI it is whatever xvfb was told to be. A screenshot is only evidence if the viewport is written down. |
| 2 | createWindow(), the ready-to-show handler |
if (!isE2E()) mainWindow?.maximize(). The other half of hunk 1 — maximizing would discard the size the constructor was given immediately. |
| 3 | initAutoUpdater() |
An early return, so the packaged run neither calls the GitHub releases feed nor pops the “Update ready” dialog over a screenshot. Unreachable in a checkout — the !app.isPackaged return above it fires first. |
What the brief expected and was not needed
- A pinned backend port. Not needed. The preload already carries the port the main process chose; the suite reads it back.
- A way to skip the daemon-lock probe. Not needed.
RESMON_STATE_DIRpointing at an empty temp directory meansreadLockFile()returnsnullon every attempt and the app spawns its own backend. It costs two 250 ms backoffs and no health probe.
How “unchanged when unset” was checked
Not by reading the diff. e2e/default-behaviour.spec.ts launches the same app
twice, from environments built by the same launchEnv() function and
differing in nothing but RESMON_E2E, and compares behaviour:
P6 RESMON_E2E UNSET {"bounds":{"width":1728,"height":1084},"maximized":true,
"workArea":{"width":1728,"height":1084},"zoom":1,
"title":"resmon","backendPort":"59956"}
P6 RESMON_E2E SET {"bounds":{"width":1440,"height":900},"maximized":false,
"workArea":{"width":1728,"height":1084},"zoom":1,
"title":"resmon","backendPort":"59997"}
Unset, the window still maximizes to the full work area. Both launches agree on
everything the flag does not control: title, renderer URL shape, the preload
bridge, isPackaged, and spawning their own backend rather than attaching to
8742.
Hunk 3 is not covered by this, and that is stated rather than worked around:
in a checkout initAutoUpdater returns before it reaches the isE2E() line, so
there is no behaviour to observe. Covering it needs a packaged app on Windows or
Linux — see below.
What src/** would want, proposed and not done
src/** was outside this spike’s grant, so these are proposals for 1.8.7:
- Export the route table from
App.tsxand import it inroutes.ts. This is the single highest-value change here: it turns a hand copy into a denominator, and makes “a route with no smoke test” impossible rather than merely unlikely.mcp_server.TOOLSis the model. data-testidon the report-viewer tabs..tab-bar .tab-btnmatched by text works today and will keep working until somebody rewords a tab.- A stable hook on each page’s root, so “the route rendered” can be
asserted against that page rather than against
main.main-contentbeing non-empty. The current assertion is honest but coarse: a page that rendered only its title bar passes it.
Q8 — how long, and how flaky
Route smoke alone — 5 of 5 green.
| Run | Wall clock | Result |
|---|---|---|
| 1 | 12.3 s | 14 passed |
| 2 | 11.7 s | 14 passed |
| 3 | 11.6 s | 14 passed |
| 4 | 11.7 s | 14 passed |
| 5 | 11.7 s | 14 passed |
The whole suite (21 tests, 6 files) — 5 of 5 green, after the two scoping fixes in Q3:
| Run | Wall clock | Result |
|---|---|---|
| 1 | 71.5 s | 21 passed |
| 2 | 47.8 s | 21 passed |
| 3 | 40.9 s | 21 passed |
| 4 | 50.9 s | 21 passed |
| 5 | 40.6 s | 21 passed |
The spread — 40.6 s to 71.5 s — is the arXiv call in search-record.spec.ts and
the npm run dist app launch in packaged.spec.ts, both of which do real work.
The route sweep itself is ~600 ms per route and barely moves.
And the runs before the fixes, because they are the more useful number:
| Configuration | Result |
|---|---|
| Whole suite, before any scoping | 0 of 3 green — YouTube ERR_ABORTED on the Dashboard |
| Whole suite, requests scoped only | 0 of 5 green — the leaked provoked console error on /analytics |
| Whole suite, both scoped + collectors reset | 5 of 5 green |
A single spec file being green five times running said nothing about the suite. That is the durable lesson from Q8 and it is why the table above reports both.
The app launches once per worker, not once per test. Fourteen cold launches would be ~110 s instead of ~12 s, which is the difference between a job that can run on every PR and one that cannot. The cost is that routes are not isolated from one another; the collectors tag every event with the route that was current when it fired, so a misattribution shows up as a surprising route rather than as nothing.
What could not be established from where this stood
Named plainly, because a spike that lists only what worked is not a spike.
- Windows and Intel macOS. One machine, arm64.
packaged.spec.tshas the path forwin-unpackedandlinux-unpackedwritten down but neither has been run. The release matrix has four targets and this covers one and a half. - A signed or notarized app. The
.appmeasured here is unsigned (CSC_IDENTITY_AUTO_DISCOVERY=false), which is what resmon ships today — Apple enrolment is deferred. Whether a hardened-runtime, signed app still accepts the Node inspector argument Playwright launches it with is untested, and hardened runtime is exactly the setting that would break it. - Gatekeeper quarantine and app translocation. The same gap Delegation 05
recorded for sqlite-vec (Ledger 30). A
.apprun from a quarantined download is a different filesystem situation from one run out ofrelease/. - The packaged app on CI. The
ui-smoke.ymljob skipspackaged.spec.ts; building an installer downloads a Python runtime and takes minutes. Packaged verification is a local, on-demand thing today. - Whether hunk 3 works.
initAutoUpdater’sRESMON_E2Ereturn is unreachable in a checkout and the packaged run was macOS, where the updater is disabled anyway. It is written from the code, not measured. - Any window-manager behaviour, on CI. Maximize and restore, the
swipegesture bound inmain.tsfor history navigation, window focus and stacking. All work locally; none is observable underxvfb-runas configured. - Why session 5 failed. Three variables changed at once. This spike establishes that the failure does not reproduce here; it does not establish which difference was responsible.
- Anything below the smoke line. Every spec here asserts that a page loads. Nothing asserts that it is correct — no visual regression, no layout assertion, no accessibility check. Whether 1.8.7 wants any of those is its decision; this spike deliberately did not make it.
Verification ledger
Per workspace/plans/HANDBACK-FORMAT.md. One row per property in the brief’s
P-list. Cannot see is mandatory and non-empty.
| P | Check | Boundary | Establishes | Cannot see | Mutation | Denominator |
|---|---|---|---|---|---|---|
P1 — the app launches and firstWindow() resolves |
e2e/fixtures/resmon-app.ts app/win fixtures; every one of the 21 tests depends on them |
real dependency, out-of-process — the app’s own Electron 41.10.6 binary, its own spawned Python backend, a real display locally and xvfb on CI | _electron.launch() starts resmon from resmon_scripts/frontend, firstWindow() resolves, and the window is serving http://127.0.0.1:<port>/index.html with the preload bridge attached |
A launch that succeeds but takes minutes; the 180 s timeout would pass it. Nothing here measures startup time. Also: launch on Windows, on Intel macOS, or from a signed or quarantined bundle | Not mutated — a launch failure is not a subtle state, and the first CI run and the packaged-app run are two independent confirmations | 2 of 2 launch modes exercised (checkout, packaged .app); packaged on macOS arm64 only |
| P2 — each of the 14 routes loads with zero console errors | e2e/smoke.spec.ts, parametrised over ROUTES |
real dependency, out-of-process — real Chromium, real backend | Each route sets location.hash, renders a non-empty main.main-content, and produces no console error and no failed request from 127.0.0.1 |
Three things. (1) A route in App.tsx that is not in routes.ts — the list is a copy. (2) A broken YouTube embed or blog <webview>, since assertions are scoped to resmon’s origins. (3) Anything about a page beyond it rendering something: wrong content, broken layout, and an unreadable page all pass |
RESMON_E2E_BREAK_ROUTE=/no-such-page fails the run, locally and on CI (33822159270). The same mutation passed against the first version of the assertion, which is how the body-vs-main.main-content gap was found |
14 of 14, from ROUTES in e2e/routes.ts — copied by hand from App.tsx, not derived. This is the weakest denominator here and proposal 1 below is the fix |
| P3 — the Search-record tab activates on click, or the DOM shows why not | e2e/search-record.spec.ts |
real dependency, out-of-process — a real trusted Chromium input event via locator.click(), against a real execution seeded through POST /api/search/dive |
The tab’s class goes tab-btn → tab-btn tab-active, the sole .tab-active element is labelled Search record, and .search-record renders after its own fetch |
Whether the tab works from a user’s pointer, on a display with a window manager and a real cursor — Playwright’s event is trusted but synthesised. And whether session 5’s failure was caused by headless mode, by raw CDP, or by the served-renderer shim: three variables changed at once | Not mutated. The before-state is asserted (classesBefore does not contain tab-active), so the transition is not a coincidence — but no deliberate break was introduced |
1 of 5 report-viewer tabs driven. Report, Log, Metadata and Progress were not clicked |
P4 — no native dialog opens and no shell call escapes |
e2e/ipc-stubs.spec.ts, both tests |
real dependency, out-of-process — real ipcMain handlers replaced in the real main process via electronApp.evaluate |
All three IPC channels answer from stubs (1 call each), and all five OS-facing functions record 0 calls across a full 14-route pass | Anything called before installIpcGuards runs — i.e. during startup. Nothing in main.ts does, but that is read from the code, not measured. Also, the app’s renderer-side callers are never pressed: this proves the stubs work, not that every button routes through them |
Not mutated. The counters distinguish “stub reached” from “OS reached” and both were observed non-zero and zero respectively in the same run, which is the discriminating evidence | 5 of 5 OS-facing calls guarded; denominator from grep -n 'dialog\.\|shell\.' electron/main.ts. 3 of 3 IPC channels, denominator from preload.ts’s exposeInMainWorld |
| P5 — the CI job fails when a route fails to load | ui-smoke.yml with break_route; run 33822159270 |
real dependency, out-of-process — a real GitHub Actions runner, xvfb, exit code 1 | A route that renders nothing makes the job red, and the message names the route and the empty element | A route that renders something wrong. The job fails on an empty outlet, not on incorrect content — every content regression in resmon’s history would have passed this | This is the mutation, and it did real work: it failed the first version of the assertion’s absence | 1 of 1 failure mode demonstrated (empty route). Other failure modes — a backend that never starts, a window that never opens — are not demonstrated |
P6 — default behaviour unchanged with RESMON_E2E unset |
e2e/default-behaviour.spec.ts |
real dependency, out-of-process — the same app launched twice, from environments built by one launchEnv() and differing only in the variable |
With the flag unset the app takes its pre-flag window (maximized to the work area on macOS; the 1280×820 constructor size under xvfb, which has no window manager) and never the E2E size; title, renderer URL shape, preload bridge, isPackaged and spawn-its-own-backend are identical across both |
Hunk 3. initAutoUpdater’s guard is unreachable in a checkout and the packaged run was macOS, where the updater is off — so 1 of the 3 hunks has no behavioural check at all. Also, this compares the flag’s own branches; it cannot see a change to main.ts that affects both paths equally |
Not mutated. The comparison is itself differential — the two launches are the control for each other | 2 of 3 RESMON_E2E hunks observed behaviourally. Denominator from the three isE2E() call sites in electron/main.ts |
Not covered
- Hunk 3 of
RESMON_E2E(the auto-updater skip) has no behavioural check. It needs a packaged app on Windows or Linux. packaged.spec.tsdoes not run on CI. It skips, with the reason in the skip message. Packaged verification is local and on demand.- Windows, Intel macOS, a signed app, a quarantined app. None launched.
- Window-manager behaviour on CI: maximize/restore, the
swipegesture, focus, stacking. - Correctness of any page. Every check here is “it rendered”. No visual regression, no layout assertion, no accessibility check, no content assertion.
Residual risk
Written by the implementer, and read against the ledger above.
- The copied route list is the biggest one. P2’s denominator is a hand copy, so the property “every route is swept” degrades silently the first time somebody adds a route. Nothing fails, nothing warns. Everything else here is sound and this one thing rots.
- Origin scoping hides two real surfaces. The YouTube embeds and the blog
<webview>are things a user sees, and the suite now cannot fail on them. A reader of a green run could reasonably believe About resmon was verified; it was verified apart from its two most fragile parts. - “It rendered” is a low bar and a green suite may read as more than it is. Every v1.8.3 interface defect — the white flash, the unwired back/forward, the inconsistent link behaviour — would pass every check in this directory. That is appropriate for a smoke suite and dangerous if the tick is read as “the interface is fine”.
- The fuse state is one hardening PR from flipping.
RunAsNodeandEnableNodeCliInspectArgumentsare enabled only because electron-builder leaves them alone. Turning them off is a reasonable security change, and it would break packaged-app verification the same day, with no warning that connects the two. - CI verifies the renderer well and the window barely. A regression in
main.ts’s window handling — the part of the app that had no test at all, which is why this spike exists — is largely still untested under xvfb.
For whoever writes the 1.8.7 brief
The three things that would change the shape of the layer, in the order they would pay off:
- Make the route list a denominator, not a copy (proposal 1 above). Every other property here is measured against a list that can silently go stale.
- Decide what to do about the two embedded third-party origins. They are the only reason the assertions are scoped, and the scoping is the one place this suite lies by omission.
- Decide whether packaged-app verification belongs in CI. It works, it is slow, and the fuse state it depends on is one hardening PR away from flipping.