Test that the IPC handlers use the modules they delegate to - #131
Merged
Conversation
Closes #129. The house pattern is to lift a handler's decision-making into a small module and test that module directly. Every test in `test/` talks to the module; none talks to the handler. So the connection between the two is untested: delete the `openExternalUrl` call from the `url:open` handler, call `shell.openExternal` in its place, and the whole suite stays green while the app loses the guard #121 added. For a module that formats a string that is a small gap. For one that exists to refuse something, the connection is the entire point. ## What changed - **`test/ipc-wiring.test.cjs`** (new) — loads `src/main.js` in an ordinary `node --test` process with `require('electron')` replaced by a recording stub, captures the callbacks main.js registers with `ipcMain.handle`, and invokes them with the delegate module stubbed. It runs the real handler body, so it fails on a deleted call, a renamed export, or a bypass added beside it — not just on missing text. Nothing in the startup path runs: the stub's `app.whenReady()` returns a promise that never settles, so requiring the file registers handlers and does nothing else. No Electron process, and the suite stays a plain `node --test`. The harness lives in the test file rather than a helper, because `node --test` discovers every `.cjs` under `test/` and would report a helper as a file of its own — and the suite is deliberately run without a path (see `scripts/run-tests-electron.cjs`). - **`src/main.js`** — the `electron-store` import moves from module load into `getStore()`. It has to: an ESM `import()` is invisible to the `Module._load` hook, so at require time it pulled in the real `electron` behind the stub's back and rejected. Deferring it also removes a startup unhandled rejection — a promise nobody was awaiting yet, at the one moment the app has no way to report it. Concurrent callers still share a single import, and a failure still surfaces to the handler that asked. The last test is a coverage guard: every channel main.js registers must be classified as wired, no-delegation, unwired-invariant or not-reachable, each with a reason, and no entry may name a channel that no longer exists. A new handler fails the suite until someone classifies it — which is what makes this a fix to the pattern rather than seven one-off tests. ## Testing 13 tests, covering `url:open` -> `external-url` (both with a spy and end-to-end through the real module: `file:` refused and logged, `https:` opened as the normalized href), `git:worktree-dirty`, `git:discard-changes`, `git:update-trunk` and `sites:add` -> `trunk-update`, the three patch channels -> `ensureAutocrlf` and `git-update.normalizeEol` against a real throwaway repository, `npm:install` and `npm:run-script` -> `npm-runner` (the environment `buildChildEnv` returned is the one that reaches `spawn`; a failing close consults `shouldRetryWithRelaxedEngines` and a `true` actually starts a second attempt), and the `before-quit` sweep -> `kill-tree`. Each assertion was mutation-checked: bypassing `openExternalUrl`, deleting `ensureAutocrlf` from `sites:add` and from the patch path, `child.kill()` in place of `killChildTree`, hand-rolling the child env instead of calling `buildChildEnv`, dropping `normalizeEol`, and adding an unclassified handler. Each fails exactly the test that names it and nothing else. Suite: **171 passing, 0 failing**, on `node --test` and again on Electron's bundled Node. `npm run lint` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first commit broke `test/electron-node-version.test.cjs` on CI, on
both platforms, while passing locally.
`node_modules/electron/index.js` resolves the binary path at module
scope and spawns Electron's installer when it is missing. So requiring
the package is not inert on a cold checkout: it starts a download into
the same `dist` directory that electron-node-version.test.cjs spawns the
binary out of — and `node --test` runs files concurrently, so that test
got a half-written framework and failed with a truncated `__TEXT`
segment. Locally the binary is already there, the require is a file
read, and nothing happens.
Two paths reached it, both from the new file:
- Stubs are built by merging over the real module, so stubbing
`src/logging.js` means requiring it, and it requires `electron`. That
require ran before the `Module._load` hook was installed. It now happens
inside it, and the hook also covers `electron/*` subpaths and anything
resolving into the package.
- `sites:add` was allowed to run past its delegation into `getStore()`,
whose `import('electron-store')` does `import {app} from 'electron'`
through the ESM loader — where `Module._load` does not apply and no hook
can help. The test now stops at the delegation, which is all it was ever
asserting, the same way the patch-channel test does.
Adds a test that fails if the real package is loaded at all, since
neither path is visible on a machine where the binary is present.
Verified by removing `node_modules/electron/path.txt`: before, the suite
prints "Downloading Electron binary..." and the new test fails; after,
neither happens.
Suite: 172 passing on both runtimes, lint clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds IPC wiring tests to ensure handlers continue using their delegated modules.
Changes:
- Adds a stubbed Electron harness and wiring coverage guard.
- Lazily initializes
electron-storeto support Node-based tests. - Review found one issue: one-way IPC channels cannot be classified as intended.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
test/ipc-wiring.test.cjs |
Tests handler-to-module wiring and channel coverage. |
src/main.js |
Defers store initialization until first use. |
Comment on lines
+659
to
+680
| test('every IPC channel is classified: wired, or explicitly not', () => { | ||
| const main = loadMain({ stubs: silentLogging() }); | ||
| const registered = main.channels(); | ||
|
|
||
| const unclassified = registered.filter((channel) => !CLASSIFIED.includes(channel)); | ||
| assert.deepEqual( | ||
| unclassified, | ||
| [], | ||
| `New IPC handler(s) with no wiring test. Add a test above and list the channel in WIRED, ` + | ||
| `or record why there is nothing to wire in NO_DELEGATION / UNWIRED_INVARIANTS / NOT_REACHABLE.` | ||
| ); | ||
|
|
||
| // A one-way channel is a handler too, and would otherwise arrive invisible to | ||
| // this guard. There are none today; the day there is one, it gets classified | ||
| // like everything else. | ||
| assert.deepEqual([...main.oneWay.keys()], [], 'ipcMain.on channels are not covered by this guard yet'); | ||
|
|
||
| // The other direction: a channel that was renamed or removed must not leave a | ||
| // stale entry behind, claiming coverage nothing provides any more. | ||
| const stale = CLASSIFIED.filter((channel) => !registered.includes(channel)); | ||
| assert.deepEqual(stale, [], 'Classified channels that main.js no longer registers'); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #129.
The house pattern is to lift a handler's decision-making into a small module and test that module directly. Every test in
test/talks to the module; none talks to the handler. So the connection between the two is untested: delete theopenExternalUrlcall from theurl:openhandler, callshell.openExternalin its place, and the whole suite stays green while the app loses the guard #121 added. For a module that formats a string that is a small gap. For one that exists to refuse something, the connection is the entire point.What changed
test/ipc-wiring.test.cjs(new) — loadssrc/main.jsin an ordinarynode --testprocess withrequire('electron')replaced by a recording stub, captures the callbacks main.js registers withipcMain.handle, and invokes them with the delegate module stubbed. It runs the real handler body, so it fails on a deleted call, a renamed export, or a bypass added beside it — not just on missing text. Nothing in the startup path runs: the stub'sapp.whenReady()returns a promise that never settles, so requiring the file registers handlers and does nothing else. No Electron process, and the suite stays a plainnode --test.The harness lives in the test file rather than a helper, because
node --testdiscovers every.cjsundertest/and would report a helper as a file of its own — and the suite is deliberately run without a path (see the comment inscripts/run-tests-electron.cjs).src/main.js— theelectron-storeimport moves from module load intogetStore(). It has to: an ESMimport()is invisible to theModule._loadhook, so at require time it pulled in the realelectronbehind the stub's back and rejected. Deferring it also removes a startup unhandled rejection — a promise nobody was awaiting yet, at the one moment the app has no way to report it. Concurrent callers still share a single import, and a failure still surfaces to the handler that asked. No behaviour change: no handler can run before a window exists.The coverage guard
The last test requires every channel main.js registers to be classified, each with a reason:
npm:kill,playground:start/stop,playground-web:start/stop. These call no module; they hold the spawn and kill invariants inline instead (the child env built by hand rather than bybuildChildEnv,shell: false,detachedon POSIX,child.kill()where the npm paths usekillChildTree). Deletingdetached:fromplayground:startstill leaves this suite green. Recorded rather than folded into "no delegation", because fixing it is a decision about main.js — see follow-ups.site:statusandwordpress:setupdo delegate, but behind a store read and a network clone respectively.ipcMain.onchannels are recorded too, so a future one-way handler cannot arrive invisible to the guard. A new handler fails the suite until someone classifies it, and a renamed one fails it from the other side. That is what makes this a fix to the pattern rather than seven one-off tests.Testing
14 tests:
url:open→external-url(both with a spy and end-to-end through the real module —file:refused and logged,https:opened as the normalized href),git:worktree-dirty/git:discard-changes/git:update-trunk/sites:add→trunk-update, the three patch channels →ensureAutocrlfandgit-update.normalizeEolagainst a real throwaway repository,npm:install/npm:run-script→npm-runner(the environmentbuildChildEnvreturned is the one that reachesspawn; a failing close consultsshouldRetryWithRelaxedEngines, and atrueactually starts a second attempt), and thebefore-quitsweep →kill-tree.Each assertion was mutation-checked — the acceptance criterion in the issue is "a handler that stops using its module fails the suite", so each mutation was applied alone and reverted:
url:opencallsshell.openExternaldirectlyensureAutocrlfdeleted fromsites:addensureAutocrlfdeleted from the patch pathchild.kill()instead ofkillChildTreeon quitbuildChildEnvnormalizeEoldropped from both sides of the diffipcMain.handleipcMain.onchannelSuite: 172 passing, 0 failing, on
node --testand again on Electron's bundled Node vianpm run test:electron.npm run lintclean across the repo.The second commit
The first commit passed locally and broke
test/electron-node-version.test.cjson both platforms in CI. Worth writing down, because the trap is invisible on any machine that has already run the app.node_modules/electron/index.jsresolves the binary path at module scope and spawns Electron's installer when it is missing. Requiring the package is therefore not inert on a cold checkout: it starts a download into the samedistdirectory thatelectron-node-version.test.cjsspawns the binary out of, andnode --testruns files concurrently. That test got a half-written framework —segment '__TEXT' load command content extends beyond end of fileon macOS, a second "Downloading Electron binary..." on Windows. Locally the binary is already there, the require is a file read, and nothing happens.Two paths reached it, both from the new file:
src/logging.jsmeans requiring it, and it requireselectron. That require ran before theModule._loadhook was installed. It now happens inside it, and the hook also coverselectron/*subpaths and anything resolving into the package.sites:addwas allowed to run past its delegation intogetStore(), whoseimport('electron-store')doesimport {app} from 'electron'through the ESM loader — whereModule._loaddoes not apply and no hook can help. The test now stops at the delegation, which is all it was ever asserting, the same way the patch-channel test does.The 14th test fails if the real package is loaded at all, since neither path is visible on a machine where the binary is present. Verified in both directions by removing
node_modules/electron/path.txt: before the fix the suite prints "Downloading Electron binary..." and that test fails; after it, neither happens.No manual pass needed: nothing here ships in the app except the lazy store import, which no handler can observe.
Review
Ran the review in
.github/instructions/code-review.instructions.md, judgement pass dispatched to a subagent with the diff and the standard. 1[fix here]· 3[follow-up].Fixed here:
runNpmWithEngineRetry, which callsensureNodeShimDir()and leaves$TMPDIR/electron-node-shims-<pid>/behind on every run, on both runtimes. It is module-local and cannot be stubbed; anafter()hook now sweeps it.Also taken, though reported as follow-ups, because they were two lines each:
ipcMain.handle;onwas a silent no-op in the stub. Now recorded and asserted.UNWIRED_INVARIANTSlist described above, with what each one holds inline.Left as follow-ups:
sites:delete(src/main.js:658) doesfse.remove()on a renderer-supplied path with no check that it is one of the registered sites. Pre-existing and unrelated to this change; filed as Deleting a site trusts the path it is given #133.Style notes, neither wrong today:
assert.equal(spawned.options.env, env)asserts object identity, which is deliberate (adeepEqualwould pass for a handler that rebuilt an identical object by hand) but would fail a futureenv: {...buildChildEnv(), X: 1}. And the'child_process'stub key now also registers under'node:child_process', so switching to the prefixed spelling in main.js cannot silently unhook it.🤖 Generated with Claude Code