You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Generalize clients/web/src/lib/downloadFile.ts beyond JSON: a downloadBlob() core (temporary-anchor download with a deferred revokeObjectURL so the download isn't aborted), fileNameFromUri() filename sanitization, and an isHttpUrl() http(s) allowlist check.
Why
The Apps host's ui/download-file support (follow-up issue) needs to download arbitrary embedded resources (text/blob) and open http(s) resource links safely. The existing downloadFile.ts only handles JSON; this issue extends the library without any UI wiring.
Reviewed at commit 26478159 (feat(web): generalize downloadFile.ts with downloadBlob, fileNameFromUri, isHttpUrl). Diff touches only clients/web/src/lib/downloadFile.ts (+55/−9) and its test (clients/web/src/test/lib/downloadFile.test.ts).
1. Smoke test (real output)
Pure helpers (fileNameFromUri, isHttpUrl) — driven in Node 25 (native type-stripping) via a throwaway script importing the real source, fed http/https/data/blob URIs, query strings, backslash paths, control/format chars, and empty input:
All correct: last path segment extracted (fwd- and back-slash split), control/format chars stripped, disallowed \/:*?"<>| collapsed to _, 255-char cap, "download" fallback; isHttpUrl allowlists http(s) only (scheme lower-cased) and returns null for every other scheme and for unparsable input. Two benign edge behaviors worth noting (not bugs, no UI consumer yet): a query string on a path-style URI is retained in the suggested name with ?→_ (e.g. data.csv?token=abc&x=1 → data.csv_token=abc&x=1), and a bare data:/blob: URI passed to fileNameFromUri yields a nonsense tail — but real call sites gate those through isHttpUrl first, so they never reach fileNameFromUri.
downloadBlob (DOM) — needs a document, so exercised under happy-dom with the committed vitest suite (mocks URL.createObjectURL/revokeObjectURL, which happy-dom doesn't ship, and fake timers). It asserts the object URL is created, the temp anchor gets the right href/download + is clicked, is removed from the body, and the revoke is deferred (not called synchronously; fires on runAllTimers), including the try/finally cleanup-on-throw path and blob-type passthrough:
✓ creates an anchor with the right href + download attributes and clicks it
✓ still cleans up the anchor + URL when click() throws (try/finally guard)
✓ passes the JSON string into the Blob constructor verbatim
✓ downloadBlob passes the given blob through and honours its type
✓ buildExportFilename … (5)
✓ fileNameFromUri … (5)
✓ isHttpUrl … (2)
Test Files 1 passed (1)
Tests 16 passed (16)
Playwright: not applicable. This PR adds library code only — no UI wiring (per the issue: "extends the library without any UI wiring"). There is no on-screen surface to drive, so a browser/visual check would have nothing to observe. happy-dom exercises the only DOM-dependent path (downloadBlob).
2. AGENTS.md compliance
Mantine / React: N/A.downloadFile.ts is a framework-free browser library (no React, no components), so the Mantine/theme/subcomponent rules don't apply.
TypeScript: pass. No any, no @ts-*/eslint-disable suppressions in the diff. downloadBlob / downloadJsonFile return void; fileNameFromUri returns string; isHttpUrl returns URL | null. npx eslint on both changed files exits clean.
Coverage: passes the numeric bar, but the file is not gate-enforced.clients/web/src/lib/** is not in the coverage include globs in clients/web/vite.config.ts (which list src/components/**, src/utils/**, clients/web/server/**, and core/**), so downloadFile.ts never appears in npm run test:coverage and the ≥90 per-file gate does not apply to it in CI. Running v8 coverage scoped to just this file gives:
File
% Stmts
% Branch
% Funcs
% Lines
Uncovered
downloadFile.ts
100
92.3
100
100
line 47
All four dimensions clear 90, so it would pass the gate if the glob covered it. The one uncovered branch (line 47, the ?? "" after uri.split(...).pop()) is a provably-dead defensive guard — String.prototype.split always returns a non-empty array, so .pop() is never undefined. Per AGENTS.md this is exactly the case for a justified /* v8 ignore … */ annotation; it's currently unannotated, which is harmless only because the file sits outside the gate.
Flagged gap:src/lib escapes the per-file coverage gate. Worth adding src/lib/**/*.{ts,tsx} to the include array (and annotating that one dead branch) so this and future lib code is actually gated — otherwise a future regression here won't be caught by coverage.
Verdict
LGTM. The three helpers behave correctly across a wide input range, downloadBlob's deferred-revoke fix is verified under happy-dom, the diff is clean TypeScript with no suppressions, and per-file coverage is 100/92.3/100/100. Only follow-up: bring src/lib/** under the coverage include glob (and add the v8 ignore on line 47) so the file is gate-enforced, not just incidentally well-tested.
Throwaway smoke script for the pure helpers (Node 25, run from /tmp — not committed)
// Imports the real source (Node 25 strips TS types natively) and prints// fileNameFromUri / isHttpUrl output for a spread of inputs.import{fileNameFromUri,isHttpUrl,}from"clients/web/src/lib/downloadFile.ts";constpad=(s,n)=>(s+" ".repeat(n)).slice(0,n);console.log("=== fileNameFromUri ===");constfnCases=["https://x/y/z/report.pdf","http://example.com/files/data.csv?token=abc&x=1","C:\\Users\\me\\report.pdf","https://x/abc*?.txt",// ZWSP + RLO override"https://x/","https://x/na:me<>|weird?.bin","data:text/plain;base64,SGVsbG8=","blob:https://x/2b3c-uuid","just-a-name.json","https://x/"+"a".repeat(300)+".txt","",];for(constcoffnCases){constout=fileNameFromUri(c);console.log(`${pad(JSON.stringify(c),44)} -> ${JSON.stringify(out)} (len ${out.length})`);}console.log("\n=== isHttpUrl ===");consturlCases=["https://example.com","http://localhost:3000/a?b=1","HTTPS://EXAMPLE.COM/Path","javascript:alert(1)","data:text/html,<b>","file:///etc/passwd","ftp://host/file","blob:https://x/uuid","not a url","",];for(constcofurlCases){constr=isHttpUrl(c);console.log(`${pad(JSON.stringify(c),34)} -> ${r===null ? "null" : JSON.stringify(r.href)}`);}
downloadBlob was exercised via the committed downloadFile.test.ts under happy-dom (needs a DOM), and per-file coverage via a throwaway vitest config scoped to src/lib/downloadFile.ts (both removed after the run).
🔗 Implemented by PR #1586. Its code is already integrated into the Wave 1 rollup branch 1579-wave-1 (see rollup PR #1601 / verification #1600). #1586 currently targets v2/main and will auto-show as Merged once the rollup lands in v2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.
Activity
cliffhall commentedon Jul 2, 2026
Smoke test & AGENTS.md audit — PR #1586
Reviewed at commit
26478159(feat(web): generalize downloadFile.ts with downloadBlob, fileNameFromUri, isHttpUrl). Diff touches onlyclients/web/src/lib/downloadFile.ts(+55/−9) and its test (clients/web/src/test/lib/downloadFile.test.ts).1. Smoke test (real output)
Pure helpers (
fileNameFromUri,isHttpUrl) — driven in Node 25 (native type-stripping) via a throwaway script importing the real source, fed http/https/data/blob URIs, query strings, backslash paths, control/format chars, and empty input:All correct: last path segment extracted (fwd- and back-slash split), control/format chars stripped, disallowed
\/:*?"<>|collapsed to_, 255-char cap,"download"fallback;isHttpUrlallowlists http(s) only (scheme lower-cased) and returnsnullfor every other scheme and for unparsable input. Two benign edge behaviors worth noting (not bugs, no UI consumer yet): a query string on a path-style URI is retained in the suggested name with?→_(e.g.data.csv?token=abc&x=1→data.csv_token=abc&x=1), and a baredata:/blob:URI passed tofileNameFromUriyields a nonsense tail — but real call sites gate those throughisHttpUrlfirst, so they never reachfileNameFromUri.downloadBlob(DOM) — needs a document, so exercised under happy-dom with the committed vitest suite (mocksURL.createObjectURL/revokeObjectURL, which happy-dom doesn't ship, and fake timers). It asserts the object URL is created, the temp anchor gets the righthref/download+ is clicked, is removed from the body, and the revoke is deferred (not called synchronously; fires onrunAllTimers), including thetry/finallycleanup-on-throw path and blob-type passthrough:Playwright: not applicable. This PR adds library code only — no UI wiring (per the issue: "extends the library without any UI wiring"). There is no on-screen surface to drive, so a browser/visual check would have nothing to observe. happy-dom exercises the only DOM-dependent path (
downloadBlob).2. AGENTS.md compliance
Mantine / React: N/A.
downloadFile.tsis a framework-free browser library (no React, no components), so the Mantine/theme/subcomponent rules don't apply.TypeScript: pass. No
any, no@ts-*/eslint-disablesuppressions in the diff.downloadBlob/downloadJsonFilereturnvoid;fileNameFromUrireturnsstring;isHttpUrlreturnsURL | null.npx eslinton both changed files exits clean.Coverage: passes the numeric bar, but the file is not gate-enforced.
clients/web/src/lib/**is not in the coverageincludeglobs inclients/web/vite.config.ts(which listsrc/components/**,src/utils/**,clients/web/server/**, andcore/**), sodownloadFile.tsnever appears innpm run test:coverageand the ≥90 per-file gate does not apply to it in CI. Running v8 coverage scoped to just this file gives:All four dimensions clear 90, so it would pass the gate if the glob covered it. The one uncovered branch (line 47, the
?? ""afteruri.split(...).pop()) is a provably-dead defensive guard —String.prototype.splitalways returns a non-empty array, so.pop()is neverundefined. Per AGENTS.md this is exactly the case for a justified/* v8 ignore … */annotation; it's currently unannotated, which is harmless only because the file sits outside the gate.Flagged gap:
src/libescapes the per-file coverage gate. Worth addingsrc/lib/**/*.{ts,tsx}to theincludearray (and annotating that one dead branch) so this and future lib code is actually gated — otherwise a future regression here won't be caught bycoverage.Verdict
LGTM. The three helpers behave correctly across a wide input range,
downloadBlob's deferred-revoke fix is verified under happy-dom, the diff is clean TypeScript with no suppressions, and per-file coverage is 100/92.3/100/100. Only follow-up: bringsrc/lib/**under the coverageincludeglob (and add thev8 ignoreon line 47) so the file is gate-enforced, not just incidentally well-tested.Throwaway smoke script for the pure helpers (Node 25, run from /tmp — not committed)
downloadBlobwas exercised via the committeddownloadFile.test.tsunder happy-dom (needs a DOM), and per-file coverage via a throwaway vitest config scoped tosrc/lib/downloadFile.ts(both removed after the run).cliffhall commentedon Jul 2, 2026
🔗 Implemented by PR #1586. Its code is already integrated into the Wave 1 rollup branch
1579-wave-1(see rollup PR #1601 / verification #1600). #1586 currently targetsv2/mainand will auto-show as Merged once the rollup lands inv2/main. The Development panel doesn't link it now because the closing keyword is on a non-default base branch.feat(web): generalize downloadFile.ts with downloadBlob, fileNameFrom…