LIFE COMPASS

0014 — A DOM for tests, and only for tests

Context

0001 makes one promise the rest of the workbook is built around: a save must never disturb the field a reader is dictating into. #24 names the test that proves it — dictation-shaped input, arriving in bursts, must survive a save cycle with the caret intact — and until the fields existed there was nothing to point it at.

The fields exist now, and they are DOM code: src/client/fields.ts upgrades every blank to a control, reads a blank’s address from data-instance and data-field on its ancestors, restores stored answers into the ones still empty, and listens for input. Node has no DOM, so none of that can be exercised by the suite as it stands.

0003 weighs every dependency against a project meant to sit untouched for years, and the count has been two since it was written. This is the first thing that has asked to make it three.

Options

O1. A hand-rolled fake covering the ten DOM APIs fields.ts uses. Rejected, and rejected on evidence rather than taste. 0013 shipped exactly that shape one slice earlier: a hand-rolled tag walker, written alongside the tests that used it, which passed everything until an adversarial pass found class = "fill" — valid HTML, resolved by every browser — sailing through both the walker and the count that was supposed to catch the walker under-seeing. A fake closest() or activeElement that differs from a browser’s in some corner gives tests that pass on broken binding code, and the binding is where a defect costs somebody the paragraph they just spoke.

O2. Test in a real browser. Rejected for now. Correct, and eventually right for the end-to-end path, but it needs a driver, a browser download and a place to run them — far more machinery than one dev dependency, for the same question.

O3. A DOM implementation, dev-only. Chosen. happy-dom, in devDependencies. It supports the things the tests turn on — element creation and replacement, ancestor traversal, focus() and document.activeElement, selectionStart/selectionEnd, and event dispatch — which were verified before it was adopted rather than assumed. C4 records where that verification was not enough.

Decision

The build-time dependency count goes from two to three. Nothing in dist/ gains a byte and a reader’s browser never sees it, but that is a smaller claim than “the count is unchanged” and the honest one: 0003 · C1 was already counting build-time dependencies.

What makes it worth paying is that 0003 · C1’s actual objection — how much of this project’s own code gets welded to a dependency — barely applies here. happy-dom appears in one import in one test file. The trade is between a dependency that can be removed in an afternoon and a fake that would have to be trusted with no way to check it, and this project has spent four review rounds learning what an unverifiable check costs.

Consequences

C1. 0003 · C1 counts two dependencies, TypeScript and a Markdown renderer, and says plainly that both are build-time only. This makes it three. Calling the first two “runtime” dependencies to keep the headline number flat would be bookkeeping rather than a fact: the site ships zero dependencies before this and zero after, and the build-time count is what actually grows. What separates happy-dom from the @types/* packages 0003 · C1 discounts is that it is a real implementation rather than declarations — which is the cost worth noticing, and the reason it earns a record.

C2. The DOM-touching client modules become testable. answers.ts and store.ts were already unit-tested — the Store interface exists so their decisions could be tested in Node without a DOM — but banner.ts and sw-update.ts were only ever verified by reading their emitted output for expected substrings, which is why three device-testing rounds found bugs the suite could not. Those two can now be exercised rather than pattern-matched; neither is in this change.

C3. A DOM in tests is not a browser. It does not lay out, does not paint, and its event loop is not a real one, so it cannot answer whether a textarea grows correctly or whether a banner is legible. Device testing stays the check for anything visual — this replaces none of it.

C4. The suite gains a way to be wrong that it did not have: a test can now pass because happy-dom behaves differently from a browser rather than because the code is right. This is not hypothetical and it did not take long. Assigning to a control’s value moves the caret to the end in every browser; happy-dom leaves it where it was. The first version of the caret test — the one 0001 and #24 exist for — therefore passed against an input handler that wrote back into the field on every save, which is the exact defect it was written to forbid. The test now counts assignments to value instead of watching the caret, because that does not depend on either behaviour. The lesson generalises: a DOM test should assert on something the code did, not on a side effect the DOM is supposed to produce.

C5. Client modules now import each other as ./keys.ts, and the emit rewrites the extension to .js. 0012 had the sources say .js so the specifier a browser resolves is the one the source contains. That held while nothing under test imported a sibling — app.ts has always imported four of them, but app.ts has no test, so Node was never asked to resolve one. fields.ts imports keys.ts and is tested, and Node cannot resolve ./keys.js from source, so the tier would have been untestable exactly where it matters most. rewriteRelativeImportExtensions is the only rewrite: no resolution, no bundling, nothing that knows how the site is served. Only build/client.ts drives the emit — tsconfig.client.json is noEmit, so its copy of the setting governs the typecheck and nothing else — and the guard is therefore on the emit: buildClient_RealRoot_EmitsEveryModuleAsBrowserReadyJavaScript rejects any emitted relative specifier not ending in .js, and now proves the rewrite ran rather than proving somebody typed the extension correctly.

C6. assert.equal may not be used on a DOM node. It passes quietly, and on failure tries to render a diff of two nodes — walking parents, children and the document until the heap is gone, so the test reports as an out-of-memory kill with no message. That was found here by writing an assertion that was simply wrong (activeElement with nothing focused is <body>, not null), and the cost of learning it that way is a twenty-five-second crash with no output at all. src/client/fields.test.ts compares nodes through an assertSame helper built on assert.ok instead.