Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).
Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.
Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.
Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.8 KiB
RB-28 — BLOB_PRESENTER token unlocks the three blob-to-browser success paths
Status: implemented · 2026-08-28 · Source finding: 02-testability.md TE-006 ·
99-backlog.md RB-28
What was wrong
Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot
meaningfully execute: StamdataStore.download()
(libs/beheer/src/application/stamdata.store.ts) did URL.createObjectURL →
document.createElement('a') → a.click() → URL.revokeObjectURL;
BriefStore.previewLetter() (apps/ssp/src/app/brief/application/brief.store.ts) and
OrgTemplateStore.proefbrief() (apps/ssp/src/app/brief/application/org-template.store.ts)
both did window.open(URL.createObjectURL(blob), '_blank'). Because the call was the
last statement of each command, TE-006 recorded the whole success path as effectively
unassertable, and download()'s two-clause guard (if (!s || !this.canDownload()) return;) as permanently dark on its true branch.
What changed
One new file, libs/shared/src/application/blob-presenter.ts, mirroring the
SESSION_PORT token already in that folder — an interface, a production
implementation, and an InjectionToken:
export interface BlobPresenter {
open(blob: Blob): void;
download(blob: Blob, filename: string): void;
}
const realBlobPresenter: BlobPresenter = {
open(blob) {
window.open(URL.createObjectURL(blob), '_blank');
},
download(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
};
export const BLOB_PRESENTER = new InjectionToken<BlobPresenter>('BLOB_PRESENTER', {
providedIn: 'root',
factory: () => realBlobPresenter,
});
open() never revokes the object URL (the tab it opens outlives the call —
BriefStore.previewLetter's original comment already said so and is preserved,
moved onto the token's own doc comment); download() does revoke, once the click has
fired. This asymmetry is preserved deliberately, not unified — the two call sites
behaved differently before this ticket and still do.
Each of the three commands now injects BLOB_PRESENTER and calls it instead of the DOM
directly:
| File | Before (last statement) | After |
|---|---|---|
stamdata.store.ts |
createObjectURL → createElement('a') → click() → revokeObjectURL (6 lines) |
this.blobPresenter.download(blob, \${s.table.id}.json`);` |
brief.store.ts |
window.open(URL.createObjectURL(r.value), '_blank') |
this.blobPresenter.open(r.value); |
org-template.store.ts |
window.open(URL.createObjectURL(r.value), '_blank') |
this.blobPresenter.open(r.value); |
OrgTemplateStore.previewUrlFor (added by RB-24, a different seam — a document
content URL for an <a href>, not a blob handoff) is untouched.
Tests added
libs/beheer/src/application/stamdata.store.spec.ts — a new
StamdataStore.download (RB-28) describe block with a recording fake BlobPresenter:
- Does not call the presenter while
canDownload()is false because nothing is dirty yet — the guard's previously-dark true branch, first clause. - Does not call the presenter while previewing a date, even with a real edit present — the guard's true branch, second clause.
- The success path, asserting
toJson(...)'s exact output reaches the file: reads the recorded blob's text and compares it byte-for-byte against a direct call totoJson(store.table()!, store.rows()), and asserts the filename isprofessions.json.
apps/ssp/src/app/brief/application/brief.store.spec.ts — the existing
BriefStore.previewLetter describe block's success test previously spied directly on
window.open/URL.createObjectURL (both already jsdom-spyable, since the properties
exist even though calling them for real throws "not implemented"). It now provides the
recording fake via BLOB_PRESENTER and asserts opened holds exactly the resolved
blob — the same outcome, reached through the new seam instead of monkey-patching two
global browser objects.
apps/ssp/src/app/brief/application/org-template.store.spec.ts (new file —
OrgTemplateStore had no spec at all before this ticket) — a
OrgTemplateStore.proefbrief (RB-28) describe block: the success path (presenter
receives the resolved blob, no error) and the failure path (presenter never reached,
error surfaced). A Partial<UploadAdapter> stub with a no-op categoriesResource
(status 'idle') satisfies the store's constructor effect without touching the
logo-upload sub-state, which these tests do not exercise.
Verified red without the fix
Broke StamdataStore.download() with an Edit (not git checkout): changed the
filename from `${s.table.id}.json` to `${s.table.id}.csv`. Ran the new
success-path spec:
AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality
Expected: "professions.json"
Received: "professions.csv"
❯ libs/beheer/src/application/stamdata.store.spec.ts:134:36
Re-applied the correct filename with a second Edit; the full stamdata.store.spec.ts
file (6 tests) went green again.
Verification
grepfor remaining DOM blob calls in all three stores —grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)"— zero matches. The only occurrences of those calls anywhere inapps/libsare insideblob-presenter.tsitself (checked with a second, unscoped grep — no fourth inlined handoff exists).npm run lint: clean.npm run dep:check: unaffected (no new import direction —libs/sharedstill does not depend onlibs/beheer; bothlibs/beheerandapps/ssp/briefimport the new token fromlibs/shared, never the reverse).npm test(all four projects): all pass — ssp 276, behandelportal 37, shared 138, beheer 26 (up from 23; +3 for the newdownload()describe block).- Coverage,
npm run test:coveragenarrowed per project:libs/beheer/src/application/stamdata.store.ts— before BRH 15 / BRF 37 (40.5% branch, confirmed against the current tree, matching TE-006's citation exactly); after BRH 25 / BRF 37 (67.6% branch).libs/beheer/src/applicationhas exactly this one file, so the module figure moves the same way.apps/ssp/src/app/brief/application/brief.store.ts— before BRH 39 / BRF 72 (54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23 already added branches (the 404-tolerance path) since the finding was written — see "What TE-006 got wrong" below. After: BRH 39 / BRF 72, unchanged — swapping the global-spy assertions for the injected fake changes how the success branch is reached in the spec, not whether it is reached; it was already covered before this ticket (see below).apps/ssp/src/app/brief/application/org-template.store.ts— no spec existed before this ticket, so there is no meaningful "before" branch figure for it specifically. After: BRH 17 / BRF 77, including bothproefbrief()branches newly covered.
npm run ci(foreground,timeout: 600000, no background/Monitor): result reported in the implementing agent's final answer.
What TE-006 got wrong
TE-006 states: "brief.store.spec.ts demonstrates this exactly: it tests
previewLetter's failure case ... and cannot test the success case." This is not
accurate for the code as it stood at the start of this ticket. The spec already had an
'opens the composed letter in a new tab on success' test that used
vi.spyOn(URL, 'createObjectURL') and vi.spyOn(window, 'open') to assert the success
path — jsdom defines both properties (as functions that throw "not implemented" if
actually invoked), so vi.spyOn can already replace them, and the pre-existing test
did. That test passed both before and after this ticket's change; this ticket did not
newly unlock previewLetter's success path, it moved an already-passing assertion off
two hand-spied global browser objects and onto the new injectable seam. git log --follow -p on the spec file shows this test dates to the WP-67 monorepo merge, not to
any of RB-22/23/24.
The seam is still worth having: StamdataStore.download()'s success path (five
DOM/API calls in a row: createObjectURL, createElement, .href, .download,
.click(), revokeObjectURL) is a materially harder thing to spy on faithfully than a
single window.open call, and was in fact still dark before this ticket (no
download() test of any kind existed). OrgTemplateStore.proefbrief() also had no
spec at all. TE-006's diagnosis (three commands share the same class of problem, one
token fixes all three) is sound; only the specific "cannot test" claim about
previewLetter overstates what was true for that one call site. Scope was not reduced
because of this — all three call sites are migrated per the ticket's own instruction to
ship them together rather than half-adopt the seam.
What this ticket did not touch
OrgTemplateStore.previewUrlFor (RB-24) — confirmed present and unchanged at
org-template.store.ts:78. libs/shared/src/application/upload-shell.service.ts
(RB-25) and upload-controller.ts (RB-26) — not read beyond what RB-24's own note
already described, not edited. libs/shared/docs/behaviour-spec.mdx — regenerated by
npm run gen:behaviour-spec (part of npm run ci) to reflect the new/renamed test
names; never hand-edited.