single-file-core 1.5.128 → 1.5.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/index.js CHANGED
@@ -464,7 +464,7 @@ class Processor {
464
464
  content = await util.getContent(this.baseURI, {
465
465
  inline: !this.options.compressContent,
466
466
  maxResourceSize: this.options.maxResourceSize,
467
- maxResourceSizeEnabled: this.options.maxResourceSizeEnabled,
467
+ maxResourceSizeEnabled: this.options.maxResourceSizeEnabled && !this.options.rootDocument,
468
468
  charset,
469
469
  frameId: this.options.windowId,
470
470
  resourceReferrer: this.options.resourceReferrer,
package/core/infobar.js CHANGED
@@ -75,6 +75,21 @@ const INFOBAR_STYLES = `
75
75
  animation-iteration-count: 2;
76
76
  }
77
77
 
78
+ .infobar:not(:focus-within):not(.infobar-focus)::after {
79
+ content: "";
80
+ position: absolute;
81
+ inset: -2px;
82
+ border: 2px solid #dd6a00;
83
+ border-radius: inherit;
84
+ opacity: 0;
85
+ pointer-events: none;
86
+ animation-name: ripple;
87
+ animation-duration: 3s;
88
+ animation-timing-function: ease-out;
89
+ animation-delay: 2s;
90
+ animation-iteration-count: 3;
91
+ }
92
+
78
93
  .infobar:valid, .infobar:not(:focus-within):not(.infobar-focus) .infobar-content {
79
94
  display: none;
80
95
  }
@@ -133,6 +148,24 @@ const INFOBAR_STYLES = `
133
148
  }
134
149
  }
135
150
 
151
+ @keyframes ripple {
152
+ 0% {
153
+ transform: scale(1);
154
+ opacity: 1;
155
+ }
156
+ 45%, 100% {
157
+ transform: scale(2);
158
+ opacity: 0;
159
+ }
160
+ }
161
+
162
+ @media (prefers-reduced-motion: reduce) {
163
+ .infobar,
164
+ .infobar:not(:focus-within):not(.infobar-focus)::after {
165
+ animation-name: none;
166
+ }
167
+ }
168
+
136
169
  .infobar:focus-within .infobar-icon, .infobar.infobar-focus .infobar-icon {
137
170
  z-index: -1;
138
171
  background-image: none;
package/deno.lock ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": "5",
3
+ "specifiers": {
4
+ "jsr:@b-fuze/deno-dom@0.1.56": "0.1.56"
5
+ },
6
+ "jsr": {
7
+ "@b-fuze/deno-dom@0.1.56": {
8
+ "integrity": "8030e2dc1d8750f1682b53462ab893d9c3470f2287feecbe22f44a88c54ab148"
9
+ }
10
+ },
11
+ "workspace": {
12
+ "packageJson": {
13
+ "dependencies": [
14
+ "npm:@eslint/js@^9.39.5",
15
+ "npm:eslint@^10.9.1"
16
+ ]
17
+ }
18
+ }
19
+ }
@@ -1079,13 +1079,31 @@ trailing bytes open it (§8.1). The parser closes the open
1079
1079
  comment or element at end of file, and `</body></html>` are implied, so the page
1080
1080
  renders the same.
1081
1081
 
1082
- Relocation is not a move at constant size, and it can end either way. Two effects pull
1083
- against each other: the room the writer sets aside, which in the reference writer is
1084
- `Math.ceil(length * 1.01) + 32` bytes — a percentage of the payload plus a constant, so
1085
- the constant dominates a small payload and the percentage a large one — and the 17 bytes
1086
- of wrapper terminator and end tags it stops emitting. Measured on three files the net ran
1087
- from 9 bytes saved to 190 bytes spent, so a writer sizing a file should quote that range
1088
- rather than a single figure.
1082
+ Relocation moves the element rather than copying it, but it is not a move at constant
1083
+ size, and wherever there is an element to move it costs bytes. The appended placement
1084
+ emits the wrapper terminator, the element and the end tags, the element plus 17; the
1085
+ relocated placement emits none of those and reserves room ahead of the archive instead,
1086
+ `Math.ceil(length * 1.01) + 32` bytes in the reference writer, where *length* is the
1087
+ element with its tags. The net is that reservation less the element and less the 17
1088
+ bytes, so about one percent of the element plus fifteen: what relocation costs is the
1089
+ margin, not a second copy. Measured on elements from 61 to 17577 bytes the formula holds
1090
+ to within a few bytes, the residual being the element itself changing length between the
1091
+ two passes, since the reservation lengthens the prologue and moves every
1092
+ central-directory offset with it. The wrapper rung sets the constant: fifteen bytes
1093
+ behind a comment, nine behind `</script>` or `]]></svg>`, six behind `</plaintext>`.
1094
+ With extraction disabled there is no element and nothing is reserved, so suppressing the
1095
+ appended run drops those 17 bytes and nothing else.
1096
+
1097
+ The two cases a writer meets differ by an order of magnitude, and the budget is what
1098
+ separates them. A relocation forced by `preventAppendedData` acts on whatever element
1099
+ exists, which on a small archive is small: 16 bytes on a 2848-byte ZIP region, 35 bytes
1100
+ on a 1.3 MB one. A relocation the budget triggers cannot be cheap, because it happens
1101
+ only once the element no longer fits: at the default 16361 that means an element past
1102
+ 16344 bytes, and 185 bytes measured on a 12.7 MB region is near the least it can cost.
1103
+ It keeps rising from there, since a relocated element sits in the prologue and no comment
1104
+ ceiling bounds it — at the ratio above, a 40 MB archive carries roughly 57 KB of element
1105
+ and costs roughly 590 bytes. A writer sizing a file should compute the cost from the
1106
+ element it produced rather than quote any of these figures.
1089
1107
 
1090
1108
  ### 5.3 Offset bookkeeping
1091
1109
 
package/eslint.config.mjs CHANGED
@@ -56,5 +56,19 @@ export default [
56
56
  rules: {
57
57
  "no-console": "off"
58
58
  }
59
+ },
60
+ {
61
+ files: ["test/capture/**", "test/run.js"],
62
+ languageOptions: {
63
+ globals: {
64
+ Deno: "readonly",
65
+ Response: "readonly",
66
+ TextDecoder: "readonly",
67
+ URL: "readonly"
68
+ }
69
+ },
70
+ rules: {
71
+ "no-console": "off"
72
+ }
59
73
  }
60
74
  ];
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "single-file-core",
3
- "version": "1.5.128",
3
+ "version": "1.5.129",
4
4
  "description": "SingleFile Core",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
7
7
  "scripts": {
8
- "test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/font-face-composite.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/pages-router.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/content-type-sniffing.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js && deno run --allow-read test/sfz-harness/charset-round-trip.js",
8
+ "test": "deno run --allow-read --allow-run test/run.js",
9
9
  "bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
10
10
  "bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
11
11
  "bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
@@ -67,7 +67,7 @@ async function createPagesArchive(pages, options) {
67
67
  }
68
68
  const manifest = {
69
69
  pages: pages.map((page, pageIndex) => ({
70
- path: getPagePath(pageIndex),
70
+ path: getPagePath(pageIndex, options.createRootDirectory),
71
71
  url: page.url,
72
72
  originalUrls: page.originalUrls,
73
73
  title: page.title
@@ -97,7 +97,7 @@ async function createPagesArchive(pages, options) {
97
97
  const aliases = {};
98
98
  const blob = await createArchive(pageData, archiveOptions, options.zipScript, async zipWriter => {
99
99
  for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
100
- const pagePath = getPagePath(pageIndex);
100
+ const pagePath = getPagePath(pageIndex, options.createRootDirectory);
101
101
  const zipReader = new ZipReader(new Uint8ArrayReader(await pages[pageIndex].getData()));
102
102
  for (const entry of await zipReader.getEntries()) {
103
103
  const filename = pagePath + entry.filename;
@@ -163,8 +163,8 @@ function getRelativePath(filename, targetFilename) {
163
163
  return "../".repeat(baseSegments.length) + targetSegments.join("/");
164
164
  }
165
165
 
166
- function getPagePath(pageIndex) {
167
- return pageIndex == 0 ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
166
+ function getPagePath(pageIndex, createRootDirectory) {
167
+ return pageIndex == 0 && !createRootDirectory ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
168
168
  }
169
169
 
170
170
  function getComment(url, options) {
@@ -0,0 +1,74 @@
1
+ # Capture harness
2
+
3
+ Tests that drive the real capture pipeline — `getPageData()`, `Processor`, `loadPage`, the batch
4
+ fetch layer — in Deno, with an injected fetch and a parser instead of a browser. Every resource a
5
+ capture asks for is served from a map declared in the suite, so there is no network and no page.
6
+
7
+ It exists because the [SFZ harness](../sfz-harness/README.md) next door covers the archive writer and
8
+ its neighbours, and nothing covered `core/index.js`. A defect in the capture pipeline could only be
9
+ caught by driving Chrome from `single-file-cli`, in another repository, against a published build.
10
+
11
+ Run them with Deno, from the repository root:
12
+
13
+ ```
14
+ npm test
15
+ ```
16
+
17
+ or this directory alone, through the same runner:
18
+
19
+ ```
20
+ deno run --allow-read --allow-run test/run.js capture
21
+ ```
22
+
23
+ or one suite at a time, which needs no runner:
24
+
25
+ ```
26
+ deno run --allow-read test/capture/resource-cap.js
27
+ ```
28
+
29
+ `common.js` and `dom.js` are named in the runner's `NOT_SUITES` list because they assert
30
+ nothing. Every other `.js` file here is run.
31
+
32
+ Unlike the SFZ harness, these download `@b-fuze/deno-dom` from JSR, so a cold cache needs network.
33
+ The version is pinned in `dom.js` and `deno.lock` carries its integrity hash, so a cold run fetches
34
+ that exact build or fails. `test/*` is ignored by `.gitignore` with one exception per directory, so a
35
+ new test directory needs its own `!` line or nothing in it is ever committed.
36
+
37
+ ## The suites
38
+
39
+ | Script | What it covers |
40
+ |---|---|
41
+ | `resource-cap.js` | That `maxResourceSize` applies to what the capture fetches and never to the page document itself. A page supplied as content is untouched, a page fetched by `saveRawPage` is untouched, an image over the cap is still dropped, frame content supplied as data is untouched, and a frame fetched in raw mode is still dropped. The raw-page case is a regression test: the cap used to empty the document, so a 2.5 MB page was saved as 525 bytes with no body, exit code 0 and no warning. |
42
+
43
+ ## How it works
44
+
45
+ `dom.js` installs the globals core reads when its modules are evaluated — `DOMParser`, `Document`,
46
+ `window`, `MutationObserver`. Import it before core, which is why `common.js` imports `single-file.js`
47
+ dynamically.
48
+
49
+ `common.js` exports `capture(resources, options)`, which returns the saved page as a string. Two
50
+ things about it are forced by core rather than chosen. `init()` builds the util instance once per
51
+ process and returns early ever after, so the injected fetch cannot be swapped per capture: one
52
+ dispatcher is installed and `capture()` points it at the map for the run in progress. And a capture
53
+ that passes no document never runs `preProcessDoc`, so the arrays it would have produced have to be
54
+ supplied empty — `processWorklets` and its neighbours read `.length` with no guard.
55
+
56
+ `frameData(windowId, baseURI, content)` builds the frame data a content script would have captured,
57
+ matched to a frame element carrying the same window id. `html(body, head)` wraps a fixture.
58
+
59
+ ## What it cannot test
60
+
61
+ Anything that reads a live document: `preProcessDoc`, `removeHiddenElements` and its marked elements,
62
+ and the `getComputedStyle` callers in `core/infobar.js` and `modules/css-fonts-minifier.js`. Leave
63
+ those options off here. The browser rigs in `single-file-cli` and `single-file-tests` cover them.
64
+
65
+ deno-dom is not a browser parser. It materializes a whole `NodeList` when `children` is read, and
66
+ `buildTrackIdMap` walks the tree child by child, so a fixture with 100k siblings overflows the stack.
67
+ Size a fixture with long text in few elements.
68
+
69
+ ## Adding a case
70
+
71
+ Same rule as the SFZ harness: add checks to the suite that already covers the area rather than making
72
+ a file per rule, write the comment that says *why* the rule exists, and confirm the check can fail.
73
+ For `resource-cap.js` that was done by reverting the `&& !this.options.rootDocument` conjunct in
74
+ `core/index.js`: exactly one check goes red, which is the check that names it.
@@ -0,0 +1,72 @@
1
+ import "./dom.js";
2
+
3
+ const { init, getPageData, helper } = await import("../../single-file.js");
4
+
5
+ const WIN_ID_ATTRIBUTE_NAME = helper.WIN_ID_ATTRIBUTE_NAME;
6
+
7
+ // preProcessDoc fills these from the live document, and it only runs when a doc is passed. A capture
8
+ // driven from here passes none, so the arrays it would have produced have to be supplied empty:
9
+ // processWorklets and its neighbours read .length with no guard.
10
+ const EMPTY_DOC_DATA = {
11
+ adoptedStyleSheets: [],
12
+ canvases: [],
13
+ fonts: [],
14
+ images: [],
15
+ posters: [],
16
+ referrer: "",
17
+ shadowRoots: [],
18
+ stylesheets: [],
19
+ usedFonts: [],
20
+ videos: [],
21
+ worklets: []
22
+ };
23
+
24
+ // init() builds the util instance once per process and returns early ever after, so the fetch cannot
25
+ // be swapped per capture. One dispatcher is installed here and capture() points it at the resources
26
+ // of the run in progress; captures are sequential, so nothing races.
27
+ let resources = new Map();
28
+
29
+ const initOptions = {
30
+ fetch: fetchResource,
31
+ frameFetch: fetchResource
32
+ };
33
+
34
+ init(initOptions);
35
+
36
+ export {
37
+ capture,
38
+ frameData,
39
+ html,
40
+ WIN_ID_ATTRIBUTE_NAME
41
+ };
42
+
43
+ async function capture(pageResources, options) {
44
+ resources = pageResources instanceof Map ? pageResources : new Map(Object.entries(pageResources));
45
+ const pageData = await getPageData({ ...EMPTY_DOC_DATA, ...options }, initOptions, null, null);
46
+ return pageData.content;
47
+ }
48
+
49
+ function fetchResource(url) {
50
+ const resource = resources.get(url);
51
+ if (!resource) {
52
+ return Promise.resolve(new Response("", { status: 404 }));
53
+ }
54
+ const contentType = resource.contentType || "text/html";
55
+ return Promise.resolve(new Response(resource.body, {
56
+ status: resource.status || 200,
57
+ headers: { "content-type": contentType }
58
+ }));
59
+ }
60
+
61
+ // A frame whose content was captured by the content script arrives as frame data keyed by the window
62
+ // id its element carries. Outside raw mode this is the only way a frame is ever filled.
63
+ function frameData(windowId, baseURI, content) {
64
+ return { ...EMPTY_DOC_DATA, windowId, baseURI, content, scrollPosition: { x: 0, y: 0 } };
65
+ }
66
+
67
+ // deno-dom materializes a whole NodeList when children is read, and buildTrackIdMap walks the tree
68
+ // child by child, so a fixture with 100k siblings overflows the stack. Size a fixture with long text
69
+ // in few elements, never with many elements.
70
+ function html(body, head = "") {
71
+ return "<!DOCTYPE html><html><head>" + head + "</head><body>" + body + "</body></html>";
72
+ }
@@ -0,0 +1,14 @@
1
+ // Three modules read globals when they are evaluated, so every one of them has to exist before core
2
+ // is imported: core/util.js captures DOMParser, and processors/hooks/content/content-hooks-frames.js
3
+ // reads globalThis.window, then calls init() and new MutationObserver(init) at module scope. That
4
+ // hook belongs to the page world and does nothing useful here; it only has to load without throwing.
5
+ // Import this module first and import single-file.js dynamically, the way common.js does.
6
+ import { DOMParser, Document } from "jsr:@b-fuze/deno-dom@0.1.56";
7
+
8
+ globalThis.DOMParser = DOMParser;
9
+ globalThis.Document = Document;
10
+ globalThis.window = globalThis;
11
+ globalThis.MutationObserver = class {
12
+ observe() { }
13
+ disconnect() { }
14
+ };
@@ -0,0 +1,79 @@
1
+ import { capture, frameData, html, WIN_ID_ATTRIBUTE_NAME } from "./common.js";
2
+
3
+ const PAGE_URL = "https://example.com/big.html";
4
+ const HOST_URL = "https://example.com/host.html";
5
+ const IMAGE_URL = "https://example.com/big.png";
6
+ const PAGE_MARKER = "BIG PAGE MARKER";
7
+ const HOST_MARKER = "HOST PAGE MARKER";
8
+
9
+ // One paragraph of 2.1 MB rather than many small ones, for the reason common.js gives.
10
+ const BIG_PAGE = html("<h1>" + PAGE_MARKER + "</h1><p>" + "filler ".repeat(300000) + "</p>");
11
+ const HOST_PAGE = html("<h1>" + HOST_MARKER + "</h1><iframe src=\"" + PAGE_URL + "\" " + WIN_ID_ATTRIBUTE_NAME + "=\"0.1\"></iframe>");
12
+ const IMAGE_PAGE = html("<h1>" + HOST_MARKER + "</h1><img src=\"" + IMAGE_URL + "\">");
13
+ const BIG_IMAGE = new Uint8Array(2 * 1024 * 1024).fill(0x21);
14
+
15
+ const resources = {
16
+ [PAGE_URL]: { body: BIG_PAGE },
17
+ [HOST_URL]: { body: HOST_PAGE },
18
+ [IMAGE_URL]: { body: BIG_IMAGE, contentType: "image/png" }
19
+ };
20
+
21
+ // One megabyte, so every fixture above is over it and the default of ten is not in the way.
22
+ const CAP = { maxResourceSizeEnabled: true, maxResourceSize: 1 };
23
+
24
+ let failed = false;
25
+
26
+ // The content a browser captured is handed to core as a string and never fetched, so the cap has no
27
+ // point at which it could fire. This is what every extension save and every non-raw CLI capture does.
28
+ {
29
+ const content = await capture(resources, { url: PAGE_URL, content: BIG_PAGE, ...CAP });
30
+ check("a page supplied as content is never capped", content.includes(PAGE_MARKER), true);
31
+ }
32
+
33
+ // The regression test. loadPage fetches the document itself in raw mode, and until rootDocument was
34
+ // excluded the cap emptied it: a 2.5 MB page came out as 525 bytes with no body at all, exit code 0
35
+ // and no warning. The cap is documented to apply to "images, fonts, stylesheets, scripts, frames,
36
+ // videos and audios", never to the page.
37
+ {
38
+ const content = await capture(resources, { url: PAGE_URL, saveRawPage: true, ...CAP });
39
+ check("a raw page over the cap keeps its content", content.includes(PAGE_MARKER), true);
40
+ }
41
+
42
+ // The control for the test above: the same cap, in the same capture, still has to drop a resource.
43
+ // A fix that exempted everything would pass the raw-page check and break the option.
44
+ {
45
+ const capped = await capture(resources, { url: HOST_URL, content: IMAGE_PAGE, ...CAP });
46
+ const uncapped = await capture(resources, { url: HOST_URL, content: IMAGE_PAGE });
47
+ check("an image over the cap is left out", capped.includes("data:image/png;base64"), false);
48
+ check("the page holding it is kept", capped.includes(HOST_MARKER), true);
49
+ check("the same image is embedded with the cap off", uncapped.includes("data:image/png;base64"), true);
50
+ }
51
+
52
+ // Frame content captured by the content script arrives as data, like the top document above, so the
53
+ // cap cannot reach it either.
54
+ {
55
+ const frames = [frameData("0.1", PAGE_URL, BIG_PAGE)];
56
+ const content = await capture(resources, { url: HOST_URL, content: HOST_PAGE, frames, ...CAP });
57
+ check("a frame supplied as data is never capped", content.includes(PAGE_MARKER), true);
58
+ check("its host is kept", content.includes(HOST_MARKER), true);
59
+ }
60
+
61
+ // In raw mode there is no frame data: resolveFrameURLs pushes a frame with no content and its runner
62
+ // fetches the frame document, which is the one caller the cap is meant for. Dropping it is correct.
63
+ {
64
+ const content = await capture(resources, { url: HOST_URL, saveRawPage: true, ...CAP });
65
+ check("a raw frame over the cap is dropped", content.includes(PAGE_MARKER), false);
66
+ check("its host is kept", content.includes(HOST_MARKER), true);
67
+ }
68
+
69
+ if (failed) {
70
+ console.log("FAILED");
71
+ Deno.exit(1);
72
+ }
73
+ console.log("OK");
74
+
75
+ function check(label, actual, expected) {
76
+ const ok = actual === expected;
77
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
78
+ failed ||= !ok;
79
+ }
package/test/run.js ADDED
@@ -0,0 +1,109 @@
1
+ // Runs every suite under test/, so that adding one means adding a file rather than editing a chain
2
+ // of shell commands. Two failure modes are worth naming, because this exists to remove the first
3
+ // without introducing the second: a suite nobody added to a hand-written list is never run and
4
+ // nobody notices, and a runner that takes every file it finds runs a scratch file that was never a
5
+ // test — single-file-tests did exactly that inside a release gate. So the files that are NOT suites
6
+ // are named below and anything else in these directories is run, which fails loudly rather than
7
+ // quietly. Keep this list in step when a helper or a tool is added.
8
+ //
9
+ // Unlike the chain it replaces, one red suite no longer hides the nineteen behind it: everything
10
+ // runs, and the summary says what failed.
11
+ //
12
+ // deno run --allow-read --allow-run test/run.js every suite
13
+ // deno run --allow-read --allow-run test/run.js cap font suites whose path matches an argument
14
+ // deno run --allow-read --allow-run test/run.js --verbose with the output of the suites that pass
15
+
16
+ const SUITE_DIRECTORIES = ["sfz-harness", "capture"];
17
+ const NOT_SUITES = [
18
+ "sfz-harness/common.js",
19
+ "sfz-harness/dom-stub.js",
20
+ "sfz-harness/gen-e2e-page.js",
21
+ "sfz-harness/search-triggers.js",
22
+ "sfz-harness/smoke.js",
23
+ "capture/common.js",
24
+ "capture/dom.js"
25
+ ];
26
+
27
+ const verbose = Deno.args.includes("--verbose");
28
+ const filters = Deno.args.filter(argument => !argument.startsWith("--"));
29
+ const suites = await findSuites();
30
+ const selected = filters.length ? suites.filter(suite => filters.some(filter => suite.includes(filter))) : suites;
31
+
32
+ if (!selected.length) {
33
+ console.log(filters.length ? `no suite matches ${filters.join(", ")}` : "no suite found");
34
+ Deno.exit(1);
35
+ }
36
+
37
+ let checksPassed = 0, checksFailed = 0;
38
+ const failures = [];
39
+ for (const suite of selected) {
40
+ const result = await runSuite(suite);
41
+ checksPassed += result.passed;
42
+ checksFailed += result.failed;
43
+ if (result.ok) {
44
+ console.log(`PASS ${suite}${result.passed ? ` (${result.passed} checks)` : ""}`);
45
+ if (verbose) {
46
+ console.log(indent(result.output));
47
+ }
48
+ } else {
49
+ failures.push(suite);
50
+ console.log(`FAIL ${suite}${result.failed ? ` (${result.failed} of ${result.passed + result.failed} checks)` : ` (exit ${result.code})`}`);
51
+ // a suite that fails a check has already said which one; a suite that crashed has not, and
52
+ // its output is the only thing that explains the exit code
53
+ console.log(indent(result.failed ? result.failedLines : result.output));
54
+ }
55
+ }
56
+
57
+ const skipped = NOT_SUITES.length;
58
+ console.log(`\n${selected.length} suites, ${checksPassed + checksFailed} checks, ${skipped} files skipped as tools or helpers`);
59
+ if (failures.length) {
60
+ console.log(`FAILED: ${failures.join(", ")}`);
61
+ Deno.exit(1);
62
+ }
63
+ console.log("all suites passed");
64
+
65
+ async function findSuites() {
66
+ const found = [];
67
+ for (const directory of SUITE_DIRECTORIES) {
68
+ const names = [];
69
+ for await (const entry of Deno.readDir(new URL(directory + "/", import.meta.url))) {
70
+ if (entry.isFile && entry.name.endsWith(".js")) {
71
+ names.push(entry.name);
72
+ }
73
+ }
74
+ names.sort();
75
+ for (const name of names) {
76
+ const path = directory + "/" + name;
77
+ if (!NOT_SUITES.includes(path)) {
78
+ found.push(path);
79
+ }
80
+ }
81
+ }
82
+ return found;
83
+ }
84
+
85
+ async function runSuite(suite) {
86
+ const command = new Deno.Command(Deno.execPath(), {
87
+ args: ["run", "--allow-read", new URL(suite, import.meta.url).pathname],
88
+ stdout: "piped",
89
+ stderr: "piped"
90
+ });
91
+ const { code, stdout, stderr } = await command.output();
92
+ const decoder = new TextDecoder();
93
+ const output = (decoder.decode(stdout) + decoder.decode(stderr)).trimEnd();
94
+ // the space matters: a suite ends on a bare "FAILED" line, which is a verdict and not a check
95
+ const lines = output.split("\n");
96
+ const failedLines = lines.filter(line => line.startsWith("FAIL ")).join("\n");
97
+ return {
98
+ code,
99
+ ok: code === 0,
100
+ output,
101
+ failedLines,
102
+ passed: lines.filter(line => line.startsWith("PASS ")).length,
103
+ failed: lines.filter(line => line.startsWith("FAIL ")).length
104
+ };
105
+ }
106
+
107
+ function indent(text) {
108
+ return text.split("\n").map(line => " " + line).join("\n");
109
+ }
@@ -14,7 +14,14 @@ Run them with Deno, from the repository root:
14
14
  npm test
15
15
  ```
16
16
 
17
- or one at a time:
17
+ which is [`test/run.js`](../run.js), the runner for every suite under `test/`. It takes
18
+ name filters, so this directory alone is:
19
+
20
+ ```
21
+ deno run --allow-read --allow-run test/run.js sfz-harness
22
+ ```
23
+
24
+ or one at a time, which needs no runner:
18
25
 
19
26
  ```
20
27
  deno run --allow-read test/sfz-harness/format-rules.js
@@ -42,12 +49,16 @@ any check failed.
42
49
  | `filename-characters.js` | That `getValidFilename` maps a full-width lookalike one character at a time — `C++` used to be saved as `C+` — while a run of characters with no lookalike still collapses to a single replacement. |
43
50
  | `zip64.js` | That the `page.pdf` record injection accounts for the zip64 end of central directory record (§5.7): all four EOCD fields left at their sentinels, the entry counts and directory size carried in the zip64 record, the directory offset pointing at the injected record, and the archive still readable. The branch runs only past 4 GiB or 65535 entries, so nothing reached it before; the suite forces zip64 through `zipWriter.options` from inside the `writeEntries` callback, with no production lever. |
44
51
  | `byte-map.js` | That the byte offsets §8.2 of the specification prints still describe what the writer emits: the prologue order, the doctype and root tag with nothing between them, the identifier's length ahead of the region, absolute EOCD offsets, and the entry order. The specimen §8.2 documents is saved from a live URL and has never been in this repository, so none of its numbers could be checked; three of them were wrong. This builds an equivalent with no network. |
52
+ | `relocation-cost.js` | That the figures §5.2 prints for relocating the extra-data element reconstruct. Two of the three came from live captures and did not: the paragraph subtracted the terminator and the end tags from the reservation without subtracting the element, which the appended placement carries too, so it over-counted by the whole element. This pins the corrected arithmetic — the cost is the reservation margin alone, it is positive on every rung whenever an element exists, and the only way relocation saves bytes is to have no element to relocate. |
45
53
  | `charset-round-trip.js` | That the encoding tables §8.4 prints still describe the WHATWG index: which 20 of the 38 encodings carry all 256 byte values through a decode injectively, the sizes of the reverse tables they need, and the five windows-1252 positions a platform codec of the same name leaves undefined. It also re-derives the reverse table the extractor ships as a literal, which no build step checks and which corrupts one byte per occurrence when wrong. |
46
54
  | `css-fonts-minifier.js` | That `removeUnusedFonts` reads the font families it prunes on correctly: a `var()` family resolved from the values the document declares and not only from the ones the body inherits, every font kept when the value is genuinely undetermined, and a multi-word family name that does not also claim a font named after its own tail. |
55
+ | `font-face-composite.js` | That several `@font-face` rules declaring the same family with the same style descriptors are one composite face and not a stack where the last rule wins, which is what CSS Fonts 4 §5.2 and §4.5.1 say: both members are kept with their own sources, faces split by `unicode-range` are all kept, an outright duplicate rule is emitted once, and a source repeated inside one rule is listed once, at the position of its later declaration. |
47
56
 
48
57
  ## The tools
49
58
 
50
- Not tests — they print, they do not assert, and CI does not run them.
59
+ Not tests — they print, they do not assert, and CI does not run them. The runner skips
60
+ them by name, in the `NOT_SUITES` list of [`test/run.js`](../run.js). Everything else in
61
+ this directory IS run, so a new file is either a suite or a line in that list.
51
62
 
52
63
  | Script | Use |
53
64
  |---|---|
@@ -4,9 +4,11 @@
4
4
  //
5
5
  // Three of its rules are worth stating, because they look arbitrary in the code:
6
6
  //
7
- // - the first page is stored at the ROOT and the others under pages/N/. The root page is what a
8
- // reader opens, so it cannot be moved into a folder without changing every relative URL the
9
- // capture already resolved.
7
+ // - the first page is stored at the ROOT and the others under pages/N/, unless
8
+ // createRootDirectory asks for a folder for the first page too. A page's resources travel with
9
+ // it, so either layout resolves; what the root buys is a reader who unzips the archive and
10
+ // opens index.html without being told where to look, and what it costs is that the first page
11
+ // shares the root with the archive's own files.
10
12
  // - a duplicate entry becomes a SYMLINK rather than being dropped. The router resolves it from
11
13
  // the alias map in the manifest and never reads it, but a plain unzip has to produce complete
12
14
  // page folders, and only a symlink gives both.
@@ -46,6 +48,37 @@ const pages = [
46
48
  (manifest.pages[0].originalUrls || []).join(" "), "https://example.com/docs/");
47
49
  }
48
50
 
51
+ // createRootDirectory gives the first page a folder of its own. Without it the first page is
52
+ // written at the root, mixed in with the archive's own files, which is the reason the router needs
53
+ // a special case at all: belongsToPage() has to read "everything not under pages/ and not named
54
+ // sfz-*" as the first page. With every page under pages/N/ that rule is a plain prefix match.
55
+ {
56
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ createRootDirectory: true, tocPage: true })));
57
+ const manifest = JSON.parse(await readEntry(entries, "sfz-pages.json"));
58
+ const toc = await readEntry(entries, "sfz-toc.html");
59
+ check("the first page is stored in a folder of its own when a root directory is asked for",
60
+ entries.has("pages/1/index.html"), true);
61
+ check("and the first page is no longer at the root", entries.has("index.html"), false);
62
+ check("the manifest names the folder of the first page too",
63
+ manifest.pages.map(page => page.path).join(" "), "pages/1/ pages/2/");
64
+ check("the table of contents links to the first page in its folder",
65
+ toc.includes("href=\"pages/1/index.html\""), true);
66
+ // the archive's own files stay at the root whatever the option says: the router finds them by
67
+ // exact name, and an archive whose sfz-pages.json moved stops being read as multi-page at all
68
+ check("the archive's own files are the only thing left at the root",
69
+ [...entries.keys()].filter(filename => !filename.includes("/")).sort().join(" "),
70
+ "sfz-pages.json sfz-toc.html");
71
+ }
72
+
73
+ // deduplication writes the link target relative to the folder the repeated entry sits in. With the
74
+ // first page at the root that walk never has a common prefix to drop; with both pages in folders it
75
+ // has to climb out of one and back into the other, which nothing exercised before
76
+ {
77
+ const entries = await readArchive(await createPagesArchive(pages, packagerOptions({ createRootDirectory: true, dedupPages: true })));
78
+ check("a repeated entry points across folders at the one that was kept",
79
+ await readEntry(entries, "pages/2/styles.css"), "../1/styles.css");
80
+ }
81
+
49
82
  // the router reads these two out of the manifest, and "auto" is the absence of a choice rather
50
83
  // than a value: writing it would pin the default of the day into every archive
51
84
  {
@@ -20,6 +20,7 @@ import * as zip from "../../vendor/zip/zip.js";
20
20
  const ARCHIVE_URL = "https://example.com/archive.html";
21
21
 
22
22
  let failed = false;
23
+ let openedEntries;
23
24
 
24
25
  const pages = [
25
26
  await makePage(1, { url: "https://example.com/docs/intro.html", title: "Intro" }),
@@ -50,6 +51,28 @@ const withoutTOC = await createPagesArchive(pages, packagerOptions());
50
51
  check("a hash that is not a route opens the first page", content, "page at \"\"");
51
52
  }
52
53
 
54
+ // createRootDirectory moves the first page into pages/1/, so the landing rule has to come from the
55
+ // manifest rather than from the root. belongsToPage() also leaves its special case behind: while
56
+ // the first page is at the root it can only be described as "everything not under pages/ and not
57
+ // named sfz-*", and a page in a folder is selected by prefix like any other. The entries handed to
58
+ // extract are the assertion, because a landing path alone would still read right if that selection
59
+ // silently picked up the archive's own files
60
+ {
61
+ const rooted = await createPagesArchive(pages, packagerOptions({ createRootDirectory: true }));
62
+ const content = await open(rooted);
63
+ check("an archive with a root directory opens on the first page in its folder", content, "page at \"pages/1/\"");
64
+ check("and the router hands it only the entries of that folder",
65
+ openedEntries.join(" "), "pages/1/index.html pages/1/manifest.json pages/1/styles.css");
66
+ }
67
+
68
+ {
69
+ const rooted = await createPagesArchive(pages, packagerOptions({ createRootDirectory: true, tocPage: true }));
70
+ check("an archive with a root directory still opens on its table of contents",
71
+ (await open(rooted)).includes("<h1>Table of contents</h1>"), true);
72
+ check("and a route still names a page in it",
73
+ await open(rooted, "#sfz/pages/1/"), "page at \"pages/1/\"");
74
+ }
75
+
53
76
  console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
54
77
  Deno.exit(failed ? 1 : 0);
55
78
 
@@ -60,7 +83,10 @@ async function open(bytes, hash = "") {
60
83
  let displayed;
61
84
  installEnvironment(hash);
62
85
  await router(new Blob([bytes]), {
63
- extract: (content, { pagePath }) => ({ docContent: "page at " + JSON.stringify(pagePath) }),
86
+ extract: (content, { entries, pagePath }) => {
87
+ openedEntries = entries.map(entry => entry.filename).sort();
88
+ return { docContent: "page at " + JSON.stringify(pagePath) };
89
+ },
64
90
  display: (document, docContent) => displayed = docContent
65
91
  });
66
92
  return displayed;
@@ -0,0 +1,94 @@
1
+ // §5.2 of doc/singlefile-archive.md quantifies what relocating the extra-data element costs. The
2
+ // figures it carried until 2026-09-12 came from two live captures and one fixture, and two of the
3
+ // three did not reconstruct: the paragraph subtracted the 17 bytes of terminator and end tags from
4
+ // the reservation without subtracting the element, which the appended placement also carries, so
5
+ // it over-counted by the whole element. This pins the corrected arithmetic. The cost is the
6
+ // reservation margin alone, it is positive on every rung whenever an element exists, and the only
7
+ // way to make relocation save bytes is to have no element to relocate.
8
+ import { makePageData, makeOptions, runProcess, freezeDate } from "./common.js";
9
+
10
+ const DECODER = new TextDecoder("windows-1252");
11
+ const OPEN_TAG = "<sfz-extra-data>";
12
+ const CLOSE_TAG = "</sfz-extra-data>";
13
+ const END_TAGS_LENGTH = "</body></html>".length;
14
+
15
+ let failed = false;
16
+
17
+ function check(label, actual, expected) {
18
+ const ok = actual === expected;
19
+ console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
20
+ failed ||= !ok;
21
+ }
22
+
23
+ function reservationSize(length) {
24
+ return Math.ceil(length * 1.01) + 32;
25
+ }
26
+
27
+ function inspect(bytes) {
28
+ const text = DECODER.decode(bytes);
29
+ const open = text.indexOf(OPEN_TAG);
30
+ const firstHeader = text.indexOf("PK\x03\x04");
31
+ return {
32
+ total: bytes.length,
33
+ element: open == -1 ? 0 : text.indexOf(CLOSE_TAG, open) + CLOSE_TAG.length - open,
34
+ relocated: open != -1 && open < firstHeader
35
+ };
36
+ }
37
+
38
+ async function build(seed, targetLength, overrides) {
39
+ const restoreDate = freezeDate();
40
+ const appended = inspect((await runProcess(makePageData(seed, targetLength), makeOptions(overrides))).bytes);
41
+ const relocated = inspect((await runProcess(makePageData(seed, targetLength), makeOptions({ ...overrides, preventAppendedData: true }))).bytes);
42
+ restoreDate();
43
+ return { appended, relocated, cost: relocated.total - appended.total };
44
+ }
45
+
46
+ // the element changes length between the two passes once the reservation is large enough to move
47
+ // the central-directory offsets it encodes, and then the reservation comes from the first pass
48
+ // while the element written into it comes from the second. These fixtures stay below that, which
49
+ // is what lets the identity be asserted exactly rather than within a tolerance.
50
+ for (const [label, seed, targetLength, closeTagLength] of [
51
+ ["comment rung", 1, 64 * 1024, "-->".length],
52
+ ["comment rung, larger", 2, 147 * 1024, "-->".length]
53
+ ]) {
54
+ const { appended, relocated, cost } = await build(seed, targetLength, {});
55
+ check(`${label}: the element keeps its length`, relocated.element, appended.element);
56
+ check(`${label}: relocates`, relocated.relocated, true);
57
+ check(`${label}: cost`, cost,
58
+ reservationSize(appended.element) - appended.element - closeTagLength - END_TAGS_LENGTH);
59
+ check(`${label}: cost is positive`, cost > 0, true);
60
+ }
61
+
62
+ // the rung sets the constant, because it is the closing tag the relocated placement stops emitting
63
+ for (const [label, closeTag] of [
64
+ ["script rung", "</script>"],
65
+ ["svg CDATA rung", "]]></svg>"],
66
+ ["plaintext rung", "</plaintext>"]
67
+ ]) {
68
+ const startTag = { "</script>": "<script type=sfz-data>", "]]></svg>": "<svg><![CDATA[", "</plaintext>": "<plaintext>" }[closeTag];
69
+ const { appended, cost } = await build(3, 64 * 1024, { extractDataFromPageTags: [startTag, closeTag] });
70
+ check(`${label}: cost`, cost,
71
+ reservationSize(appended.element) - appended.element - closeTag.length - END_TAGS_LENGTH);
72
+ check(`${label}: cost is positive`, cost > 0, true);
73
+ }
74
+
75
+ // with no element there is nothing to reserve, so the appended run is dropped and nothing replaces
76
+ // it. This is the only case in which suppressing the run makes the file smaller.
77
+ {
78
+ const { appended, cost } = await build(4, 64 * 1024, { extractDataFromPage: false });
79
+ check("extraction disabled: no element", appended.element, 0);
80
+ check("extraction disabled: cost", cost, -("-->".length + END_TAGS_LENGTH));
81
+ }
82
+
83
+ // the budget triggers relocation once the element no longer fits beside the terminator and the end
84
+ // tags, so the boundary is exactly maxAppendedDataLength - 3 - 14
85
+ {
86
+ const { appended } = await build(5, 64 * 1024, {});
87
+ const fits = appended.element + "-->".length + END_TAGS_LENGTH;
88
+ const atBoundary = await build(5, 64 * 1024, { maxAppendedDataLength: fits });
89
+ const belowBoundary = await build(5, 64 * 1024, { maxAppendedDataLength: fits - 1 });
90
+ check("an element that exactly fits the budget stays appended", atBoundary.appended.relocated, false);
91
+ check("one byte less of budget relocates it", belowBoundary.appended.relocated, true);
92
+ }
93
+
94
+ Deno.exit(failed ? 1 : 0);