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 +1 -1
- package/core/infobar.js +33 -0
- package/deno.lock +19 -0
- package/doc/singlefile-archive.md +25 -7
- package/eslint.config.mjs +14 -0
- package/package.json +2 -2
- package/processors/compression/compression-packager.js +4 -4
- package/test/capture/README.md +74 -0
- package/test/capture/common.js +72 -0
- package/test/capture/dom.js +14 -0
- package/test/capture/resource-cap.js +79 -0
- package/test/run.js +109 -0
- package/test/sfz-harness/README.md +13 -2
- package/test/sfz-harness/pages-archive.js +36 -3
- package/test/sfz-harness/pages-router.js +27 -1
- package/test/sfz-harness/relocation-cost.js +94 -0
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
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 }) =>
|
|
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);
|