single-file-core 1.5.127 → 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/core/util.js +34 -6
- package/deno.lock +19 -0
- package/doc/singlefile-archive.md +25 -7
- package/eslint.config.mjs +14 -0
- package/package.json +24 -24
- package/processors/compression/compression-packager.js +17 -16
- package/processors/compression/compression-router.js +1 -1
- 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 +16 -3
- package/test/sfz-harness/content-type-sniffing.js +83 -0
- package/test/sfz-harness/pages-archive.js +64 -4
- package/test/sfz-harness/pages-router.js +143 -0
- 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/core/util.js
CHANGED
|
@@ -63,6 +63,8 @@ const CONTENT_TYPE_EXTENSIONS = {
|
|
|
63
63
|
"font/collection": ".ttc"
|
|
64
64
|
};
|
|
65
65
|
const CONTENT_TYPE_OCTET_STREAM = "application/octet-stream";
|
|
66
|
+
const TRANSPORT_STREAM_SYNC_BYTE = 71;
|
|
67
|
+
const TRANSPORT_STREAM_PACKET_SIZE = 188;
|
|
66
68
|
const CONTENT_TYPES_HTML = ["text/html", "application/xhtml+xml"];
|
|
67
69
|
const EXPECTED_TYPES_MEDIA = ["font", "image", "video", "audio"];
|
|
68
70
|
|
|
@@ -295,11 +297,11 @@ function getInstance(utilOptions) {
|
|
|
295
297
|
} catch (error) {
|
|
296
298
|
// ignored
|
|
297
299
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
const guessedContentType = guessMIMEType(options.expectedType, buffer);
|
|
301
|
+
if (guessedContentType) {
|
|
302
|
+
contentType = guessedContentType;
|
|
303
|
+
} else if (!contentType || (contentType == CONTENT_TYPE_OCTET_STREAM && options.asBinary)) {
|
|
304
|
+
contentType = options.contentType ? options.contentType : options.asBinary ? CONTENT_TYPE_OCTET_STREAM : "";
|
|
303
305
|
}
|
|
304
306
|
if (!charset && options.charset) {
|
|
305
307
|
charset = options.charset;
|
|
@@ -403,6 +405,24 @@ function guessMIMEType(expectedType, buffer) {
|
|
|
403
405
|
if (compareBytes([255, 255, 255], [255, 216, 255])) {
|
|
404
406
|
return "image/jpeg";
|
|
405
407
|
}
|
|
408
|
+
if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 97, 118, 105, 102]) ||
|
|
409
|
+
compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 97, 118, 105, 115])) {
|
|
410
|
+
return "image/avif";
|
|
411
|
+
}
|
|
412
|
+
if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 105, 99]) ||
|
|
413
|
+
compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 105, 120]) ||
|
|
414
|
+
compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 118, 99]) ||
|
|
415
|
+
compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 104, 101, 118, 120])) {
|
|
416
|
+
return "image/heic";
|
|
417
|
+
}
|
|
418
|
+
if (compareBytes([255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 12, 74, 88, 76, 32, 13, 10, 135, 10]) ||
|
|
419
|
+
compareBytes([255, 255], [255, 10])) {
|
|
420
|
+
return "image/jxl";
|
|
421
|
+
}
|
|
422
|
+
if (compareBytes([255, 255, 255, 255], [73, 73, 42, 0]) ||
|
|
423
|
+
compareBytes([255, 255, 255, 255], [77, 77, 0, 42])) {
|
|
424
|
+
return "image/tiff";
|
|
425
|
+
}
|
|
406
426
|
}
|
|
407
427
|
if (expectedType == "font") {
|
|
408
428
|
if (compareBytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255],
|
|
@@ -444,7 +464,7 @@ function guessMIMEType(expectedType, buffer) {
|
|
|
444
464
|
if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 51, 103])) {
|
|
445
465
|
return "video/3gpp";
|
|
446
466
|
}
|
|
447
|
-
if (
|
|
467
|
+
if (isTransportStream()) {
|
|
448
468
|
return "video/mp2t";
|
|
449
469
|
}
|
|
450
470
|
}
|
|
@@ -475,6 +495,14 @@ function guessMIMEType(expectedType, buffer) {
|
|
|
475
495
|
}
|
|
476
496
|
}
|
|
477
497
|
|
|
498
|
+
function isTransportStream() {
|
|
499
|
+
const value = new Uint8Array(buffer);
|
|
500
|
+
return value.length > TRANSPORT_STREAM_PACKET_SIZE * 2 &&
|
|
501
|
+
value[0] == TRANSPORT_STREAM_SYNC_BYTE &&
|
|
502
|
+
value[TRANSPORT_STREAM_PACKET_SIZE] == TRANSPORT_STREAM_SYNC_BYTE &&
|
|
503
|
+
value[TRANSPORT_STREAM_PACKET_SIZE * 2] == TRANSPORT_STREAM_SYNC_BYTE;
|
|
504
|
+
}
|
|
505
|
+
|
|
478
506
|
function compareBytes(mask, pattern) {
|
|
479
507
|
let patternMatch = true, index = 0;
|
|
480
508
|
if (buffer.byteLength >= pattern.length) {
|
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,26 +1,26 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
2
|
+
"name": "single-file-core",
|
|
3
|
+
"version": "1.5.129",
|
|
4
|
+
"description": "SingleFile Core",
|
|
5
|
+
"author": "Gildas Lormeau",
|
|
6
|
+
"license": "AGPL-3.0-or-later",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "deno run --allow-read --allow-run test/run.js",
|
|
9
|
+
"bump-patch": "npm version patch --no-git-tag-version && npm run bump-commit",
|
|
10
|
+
"bump-minor": "npm version minor --no-git-tag-version && npm run bump-commit",
|
|
11
|
+
"bump-major": "npm version major --no-git-tag-version && npm run bump-commit",
|
|
12
|
+
"bump-commit": "git commit -m \"bump up version\" package.json package-lock.json"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/gildas-lormeau/single-file-core.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/gildas-lormeau/single-file-core/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/gildas-lormeau/single-file-core#readme",
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@eslint/js": "^9.39.5",
|
|
24
|
+
"eslint": "^10.9.1"
|
|
25
|
+
}
|
|
26
26
|
}
|
|
@@ -32,7 +32,8 @@ import {
|
|
|
32
32
|
} from "./../../vendor/zip/zip.js";
|
|
33
33
|
import {
|
|
34
34
|
createArchive,
|
|
35
|
-
escapeHTML
|
|
35
|
+
escapeHTML,
|
|
36
|
+
PROCESS_OPTION_NAMES
|
|
36
37
|
} from "./compression.js";
|
|
37
38
|
|
|
38
39
|
const browser = globalThis.browser;
|
|
@@ -46,6 +47,13 @@ const TOC_STYLE = "body{font-family:system-ui,sans-serif;margin:2em auto;max-wid
|
|
|
46
47
|
"summary{cursor:pointer;font-weight:bold;margin:.5em 0}" +
|
|
47
48
|
"details{padding-left:1em}ul{margin:.25em 0;padding-left:1.5em}" +
|
|
48
49
|
"@media(prefers-color-scheme:dark){body{background-color:#111;color:#eee}a{color:#8ab4f8}a:visited{color:#c58af9}}";
|
|
50
|
+
const ARCHIVE_EXCLUDED_OPTION_NAMES = [
|
|
51
|
+
"createRootDirectory",
|
|
52
|
+
"disableCompression",
|
|
53
|
+
"insertTextBody",
|
|
54
|
+
"password",
|
|
55
|
+
"url"
|
|
56
|
+
];
|
|
49
57
|
const COMMENT_HEADER = "Page saved with SingleFile";
|
|
50
58
|
const SYMLINK_UNIX_MODE = 0o120777;
|
|
51
59
|
|
|
@@ -59,7 +67,7 @@ async function createPagesArchive(pages, options) {
|
|
|
59
67
|
}
|
|
60
68
|
const manifest = {
|
|
61
69
|
pages: pages.map((page, pageIndex) => ({
|
|
62
|
-
path: getPagePath(pageIndex),
|
|
70
|
+
path: getPagePath(pageIndex, options.createRootDirectory),
|
|
63
71
|
url: page.url,
|
|
64
72
|
originalUrls: page.originalUrls,
|
|
65
73
|
title: page.title
|
|
@@ -80,23 +88,16 @@ async function createPagesArchive(pages, options) {
|
|
|
80
88
|
};
|
|
81
89
|
const archiveOptions = {
|
|
82
90
|
url: pages[0].url,
|
|
83
|
-
multiPageArchive: true
|
|
84
|
-
selfExtractingArchive: options.selfExtractingArchive,
|
|
85
|
-
extractDataFromPage: options.extractDataFromPage,
|
|
86
|
-
preventAppendedData: options.preventAppendedData,
|
|
87
|
-
declareAppendedData: options.declareAppendedData,
|
|
88
|
-
embeddedPdf: options.embeddedPdf,
|
|
89
|
-
embeddedImage: options.embeddedImage,
|
|
90
|
-
includeBOM: options.includeBOM,
|
|
91
|
-
insertMetaCSP: options.insertMetaCSP,
|
|
92
|
-
insertCanonicalLink: options.insertCanonicalLink,
|
|
93
|
-
insertMetaNoIndex: options.insertMetaNoIndex
|
|
91
|
+
multiPageArchive: true
|
|
94
92
|
};
|
|
93
|
+
PROCESS_OPTION_NAMES
|
|
94
|
+
.filter(name => !ARCHIVE_EXCLUDED_OPTION_NAMES.includes(name) && !(name in archiveOptions))
|
|
95
|
+
.forEach(name => archiveOptions[name] = options[name]);
|
|
95
96
|
const writtenEntries = options.dedupPages ? new Map() : undefined;
|
|
96
97
|
const aliases = {};
|
|
97
98
|
const blob = await createArchive(pageData, archiveOptions, options.zipScript, async zipWriter => {
|
|
98
99
|
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
|
|
99
|
-
const pagePath = getPagePath(pageIndex);
|
|
100
|
+
const pagePath = getPagePath(pageIndex, options.createRootDirectory);
|
|
100
101
|
const zipReader = new ZipReader(new Uint8ArrayReader(await pages[pageIndex].getData()));
|
|
101
102
|
for (const entry of await zipReader.getEntries()) {
|
|
102
103
|
const filename = pagePath + entry.filename;
|
|
@@ -162,8 +163,8 @@ function getRelativePath(filename, targetFilename) {
|
|
|
162
163
|
return "../".repeat(baseSegments.length) + targetSegments.join("/");
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
function getPagePath(pageIndex) {
|
|
166
|
-
return pageIndex == 0 ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
|
|
166
|
+
function getPagePath(pageIndex, createRootDirectory) {
|
|
167
|
+
return pageIndex == 0 && !createRootDirectory ? "" : PAGES_PREFIX + (pageIndex + 1) + "/";
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
function getComment(url, options) {
|
|
@@ -202,7 +202,7 @@ async function router(content, { extract, display }) {
|
|
|
202
202
|
function parseRoute() {
|
|
203
203
|
const hash = location.hash;
|
|
204
204
|
const routed = !hash || hash.startsWith(ROUTE_PREFIX);
|
|
205
|
-
let path = pages[0].path;
|
|
205
|
+
let path = routed && tocEntry ? TOC_ROUTE : pages[0].path;
|
|
206
206
|
let fragment;
|
|
207
207
|
if (routed && hash) {
|
|
208
208
|
({ path, fragment } = parseRouteHash(hash));
|
|
@@ -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
|
|
@@ -34,18 +41,24 @@ any check failed.
|
|
|
34
41
|
| `css-property-filter.js` | That the declaration filter keeps a property css-tree's dictionary does not know (`stop-color`, `flood-opacity`, anything newer than the pinned build) and still drops a genuinely invalid value. |
|
|
35
42
|
| `adopted-stylesheets-hook.js` | That the page-world hook answers the adopted-stylesheets request for a CLOSED shadow root, which its host does not expose. |
|
|
36
43
|
| `inlined-functions.js` | That a function serialized into a self-extracting archive names nothing outside itself. An import survives bundling and still reads correctly, and the archive then throws a bare `ReferenceError` and renders nothing. |
|
|
37
|
-
| `pages-archive.js` | That `createPagesArchive` packs several pages into one archive correctly: the first page at the root and the others in folders, the manifest, the symlink a deduplicated entry leaves behind, and the escaping of crawled titles in both tables of contents. |
|
|
44
|
+
| `pages-archive.js` | That `createPagesArchive` packs several pages into one archive correctly: the first page at the root and the others in folders, the manifest, the symlink a deduplicated entry leaves behind, and the escaping of crawled titles in both tables of contents. Also that the options reaching the archive writer are derived from `PROCESS_OPTION_NAMES` rather than hand-listed — a hand copy dropped `maxAppendedDataLength` for months — and that `password` stays out of that derivation, since forwarding it would make the writer withhold the prologue's title as if the archive were encrypted while the table of contents and every entry comment still rode in that same cleartext prologue. |
|
|
45
|
+
| `pages-router.js` | That the router opens a multi-page archive on the page a reader expects: the table of contents when the archive stores one, the first page when it does not, and the page a route in the hash names whatever else is stored. The router is inlined into the archive as source text and only ever ran inside a saved page, so nothing drove it before; the table of contents shipped stored, routable and unreachable. |
|
|
46
|
+
| `content-type-sniffing.js` | That a magic-byte match beats the `Content-Type` header, and that the header survives when nothing matches. science.org serves its woff2 files as `text/plain`, which used to be trusted, so the fonts were embedded as `data:text/plain` and the SFZ writer deflated a file that is already Brotli-compressed. Two cases guard the rules themselves rather than the outcome: a video whose first byte is `G` must not be relabelled `video/mp2t`, and the generic `mif1` HEIF brand must identify nothing, because an AVIF can carry it and calling it HEIC would name a format no browser decodes. |
|
|
38
47
|
| `entry-compression.js` | That an entry is deflated or stored on the content type the server sent, not on the extension alone — a module served as `text/javascript` from a `.ts` URL used to go in uncompressed — and that an unrecognized `application/octet-stream` still stays stored. |
|
|
39
48
|
| `filename-max-length.js` | That `formatFilename` counts the ellipsis as well as the extension in the budget it truncates to, so a filename at `filenameMaxLength` stays at it, and that a limit shorter than the extension does not reach `Blob.slice` with a negative start. |
|
|
40
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. |
|
|
41
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. |
|
|
42
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. |
|
|
43
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. |
|
|
44
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. |
|
|
45
56
|
|
|
46
57
|
## The tools
|
|
47
58
|
|
|
48
|
-
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.
|
|
49
62
|
|
|
50
63
|
| Script | Use |
|
|
51
64
|
|---|---|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `core/util.js` used to trust any Content-Type the server sent and sniff the bytes only when the
|
|
2
|
+
// header was missing or `application/octet-stream`. science.org serves its woff2 files as
|
|
3
|
+
// `text/plain;charset=UTF-8` — 12 of them — and every one was embedded as `data:text/plain`, which
|
|
4
|
+
// costs twice: a browser with no `format()` hint in the `@font-face` rule has nothing left to
|
|
5
|
+
// identify the font by, and the SFZ writer deflates a file that is already Brotli-compressed
|
|
6
|
+
// because it decides compression from the content type.
|
|
7
|
+
//
|
|
8
|
+
// The rule now: a magic-byte match wins over the header, and the header is kept only when nothing
|
|
9
|
+
// matches. That raises the bar for the sniffer's own rules, which is why `video/mp2t` no longer
|
|
10
|
+
// matches on a single `0x47` byte — as a last resort behind a missing header that was tolerable,
|
|
11
|
+
// as an override of a correct header it would relabel any video whose first byte is `G`. It now
|
|
12
|
+
// wants the sync byte at the 188-byte packet stride, and the case below is the regression guard.
|
|
13
|
+
/* global Response */
|
|
14
|
+
|
|
15
|
+
import "./dom-stub.js";
|
|
16
|
+
// util.js pulls in the page-world hooks, which register a document listener as they are evaluated.
|
|
17
|
+
// None of it is exercised here; the stubs exist so that importing util.js is possible at all
|
|
18
|
+
globalThis.window = globalThis.window || {};
|
|
19
|
+
globalThis.document = globalThis.document || {};
|
|
20
|
+
globalThis.Document = globalThis.Document || class { };
|
|
21
|
+
globalThis.MutationObserver = globalThis.MutationObserver || class {
|
|
22
|
+
observe() { }
|
|
23
|
+
};
|
|
24
|
+
const { getInstance } = await import("./../../core/util.js");
|
|
25
|
+
|
|
26
|
+
const FONT_URL = "https://example.com/font";
|
|
27
|
+
const WOFF2 = bytes([0x77, 0x4F, 0x46, 0x32], 64);
|
|
28
|
+
const PNG = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 64);
|
|
29
|
+
const AVIF = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 64);
|
|
30
|
+
const WEBP2 = bytes([0x77, 0x70, 0x32, 0x20], 64);
|
|
31
|
+
const HEIF_MIF1 = bytes([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 0x6D, 0x69, 0x66, 0x31], 64);
|
|
32
|
+
const NOT_A_TRANSPORT_STREAM = bytes([0x47, 0x53, 0x54, 0x00], 512);
|
|
33
|
+
|
|
34
|
+
let failed = false;
|
|
35
|
+
|
|
36
|
+
// [label, bytes, expectedType, the Content-Type the server sent, the type that must reach the data URI]
|
|
37
|
+
const CASES = [
|
|
38
|
+
["a woff2 served as text/plain is embedded as font/woff2", WOFF2, "font", "text/plain;charset=UTF-8", "font/woff2"],
|
|
39
|
+
["a woff2 served as font/woff2 is unchanged", WOFF2, "font", "font/woff2", "font/woff2"],
|
|
40
|
+
["a woff2 served with no type at all is embedded as font/woff2", WOFF2, "font", undefined, "font/woff2"],
|
|
41
|
+
["a png served as text/plain is embedded as image/png", PNG, "image", "text/plain", "image/png"],
|
|
42
|
+
["an avif served as application/octet-stream is embedded as image/avif", AVIF, "image", "application/octet-stream", "image/avif"],
|
|
43
|
+
// "mif1" is the generic HEIF brand and an AVIF may carry it too, so it identifies nothing on
|
|
44
|
+
// its own: claiming HEIC here would relabel an AVIF as a format no browser decodes
|
|
45
|
+
["an ambiguous HEIF brand keeps the type the server sent", HEIF_MIF1, "image", "image/avif", "image/avif"],
|
|
46
|
+
// nothing matches these bytes, so the header is all there is and it has to survive
|
|
47
|
+
["a format the sniffer does not know keeps the type the server sent", WEBP2, "image", "image/webp2", "image/webp2"],
|
|
48
|
+
["a format the sniffer does not know and no type falls back to octet-stream", WEBP2, "image", undefined, "application/octet-stream"],
|
|
49
|
+
// the byte is 0x47, the packet stride is not, so this is not a transport stream
|
|
50
|
+
["a video starting with G is not relabelled as mp2t", NOT_A_TRANSPORT_STREAM, "video", "video/quicktime", "video/quicktime"]
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
for (const [label, data, expectedType, sentContentType, expectedContentType] of CASES) {
|
|
54
|
+
const { data: dataURI } = await fetchContent(data, expectedType, sentContentType);
|
|
55
|
+
check(label, readDataURIType(dataURI), expectedContentType);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
|
|
59
|
+
Deno.exit(failed ? 1 : 0);
|
|
60
|
+
|
|
61
|
+
function fetchContent(data, expectedType, contentType) {
|
|
62
|
+
const util = getInstance({
|
|
63
|
+
fetch: async () => new Response(data, { headers: contentType ? { "content-type": contentType } : {} })
|
|
64
|
+
});
|
|
65
|
+
return util.getContent(FONT_URL, { asBinary: true, inline: true, expectedType });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readDataURIType(dataURI) {
|
|
69
|
+
const indexSeparator = dataURI.indexOf(";");
|
|
70
|
+
return dataURI.substring("data:".length, indexSeparator == -1 ? dataURI.indexOf(",") : indexSeparator);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function bytes(signature, length) {
|
|
74
|
+
const value = new Uint8Array(length);
|
|
75
|
+
value.set(signature);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function check(label, actual, expected) {
|
|
80
|
+
const ok = actual === expected;
|
|
81
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
|
|
82
|
+
failed ||= !ok;
|
|
83
|
+
}
|
|
@@ -4,16 +4,18 @@
|
|
|
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.
|
|
13
15
|
// - the titles written into the table of contents are CRAWLED, so they are attacker-controlled
|
|
14
16
|
// text going into an href attribute and into element content. Both escapers are checked here.
|
|
15
17
|
import "./dom-stub.js";
|
|
16
|
-
import { makePageData, makeOptions, runProcess } from "./common.js";
|
|
18
|
+
import { makePageData, makeOptions, runProcess, freezeDate } from "./common.js";
|
|
17
19
|
import { createPagesArchive } from "../../processors/compression/compression-packager.js";
|
|
18
20
|
import { ZipReader, ZipWriter, BlobReader, TextReader, TextWriter, Uint8ArrayWriter } from "../../vendor/zip/zip.js";
|
|
19
21
|
|
|
@@ -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
|
{
|
|
@@ -175,6 +208,33 @@ const pages = [
|
|
|
175
208
|
new TextDecoder("windows-1252").decode(await createPagesArchive(pages, packagerOptions())).includes("<nav><ul>"), false);
|
|
176
209
|
}
|
|
177
210
|
|
|
211
|
+
// the options handed to the archive writer are DERIVED from PROCESS_OPTION_NAMES, not hand-listed.
|
|
212
|
+
// The hand copy carried eleven names and silently dropped maxAppendedDataLength, so
|
|
213
|
+
// --max-appended-data-length did nothing on any multi-page save and nothing failed for months.
|
|
214
|
+
// A one-byte budget has to reach the writer, where it is indistinguishable from refusing to append
|
|
215
|
+
{
|
|
216
|
+
const unfreeze = freezeDate();
|
|
217
|
+
try {
|
|
218
|
+
const budgeted = await createPagesArchive(pages, packagerOptions({ maxAppendedDataLength: 1 }));
|
|
219
|
+
const prevented = await createPagesArchive(pages, packagerOptions({ preventAppendedData: true }));
|
|
220
|
+
const unbudgeted = await createPagesArchive(pages, packagerOptions());
|
|
221
|
+
check("a one-byte appended-data budget reaches the archive writer", equalData(budgeted, prevented), true);
|
|
222
|
+
check("and appending is what the writer does without one", equalData(unbudgeted, prevented), false);
|
|
223
|
+
} finally {
|
|
224
|
+
unfreeze();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// `password` is the one name the derivation must NOT forward. An encrypted multi-page archive
|
|
229
|
+
// cannot be written yet, and forwarding the password would half-ship it: the writer would start
|
|
230
|
+
// withholding the prologue's title as if the archive were encrypted, while the table of contents
|
|
231
|
+
// and every entry comment — each one a resource URL — kept riding in that same cleartext prologue
|
|
232
|
+
{
|
|
233
|
+
const prologue = new TextDecoder("windows-1252").decode(await createPagesArchive(pages, packagerOptions({ password: "secret" })));
|
|
234
|
+
check("a password is not forwarded to the archive writer",
|
|
235
|
+
prologue.includes("<title>Intro & "start" <b></title>"), true);
|
|
236
|
+
}
|
|
237
|
+
|
|
178
238
|
console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
|
|
179
239
|
Deno.exit(failed ? 1 : 0);
|
|
180
240
|
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The router picks the page a multi-page archive opens on. Nothing drove it until this file: the
|
|
2
|
+
// function is inlined into the archive as source text, so it only ever ran inside a saved page,
|
|
3
|
+
// and the suites around it checked what the packager WROTE rather than what a reader would see.
|
|
4
|
+
// That is how the table of contents shipped unreachable. It was stored, the route existed, and
|
|
5
|
+
// no link and no landing rule pointed at it, so --crawl-save-archive-toc looked like it did
|
|
6
|
+
// nothing at all.
|
|
7
|
+
//
|
|
8
|
+
// The rule this file pins: an archive that stores a table of contents opens on it, and one that
|
|
9
|
+
// does not opens on the first page. Two cases guard the edges of that rule. A route in the hash
|
|
10
|
+
// names a page explicitly and has to win over the landing rule, or every deep link into an
|
|
11
|
+
// archive would land on the table of contents instead. A hash that is NOT a route is a plain
|
|
12
|
+
// fragment, and the only page it can mean is the first one, which is where the archive used to
|
|
13
|
+
// land before the fragment was ever read.
|
|
14
|
+
import "./dom-stub.js";
|
|
15
|
+
import { makePageData, makeOptions, runProcess } from "./common.js";
|
|
16
|
+
import { createPagesArchive } from "../../processors/compression/compression-packager.js";
|
|
17
|
+
import { router } from "../../processors/compression/compression-router.js";
|
|
18
|
+
import * as zip from "../../vendor/zip/zip.js";
|
|
19
|
+
|
|
20
|
+
const ARCHIVE_URL = "https://example.com/archive.html";
|
|
21
|
+
|
|
22
|
+
let failed = false;
|
|
23
|
+
let openedEntries;
|
|
24
|
+
|
|
25
|
+
const pages = [
|
|
26
|
+
await makePage(1, { url: "https://example.com/docs/intro.html", title: "Intro" }),
|
|
27
|
+
await makePage(2, { url: "https://example.com/docs/api/reference.html", title: "Reference" })
|
|
28
|
+
];
|
|
29
|
+
const withTOC = await createPagesArchive(pages, packagerOptions({ tocPage: true }));
|
|
30
|
+
const withoutTOC = await createPagesArchive(pages, packagerOptions());
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
const content = await open(withTOC);
|
|
34
|
+
check("an archive holding a table of contents opens on it", content.includes("<h1>Table of contents</h1>"), true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
const content = await open(withoutTOC);
|
|
39
|
+
check("an archive holding no table of contents opens on the first page", content, "page at \"\"");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
const content = await open(withTOC, "#sfz/pages/2/");
|
|
44
|
+
check("a route in the hash names the page to open", content, "page at \"pages/2/\"");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// a bare fragment is what a hand-written link into the saved page looks like. The router scrolls
|
|
48
|
+
// to it after rendering, and the table of contents is not the document it belongs to
|
|
49
|
+
{
|
|
50
|
+
const content = await open(withTOC, "#introduction");
|
|
51
|
+
check("a hash that is not a route opens the first page", content, "page at \"\"");
|
|
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
|
+
|
|
76
|
+
console.log(failed ? "\nsome checks FAILED" : "\nall checks passed");
|
|
77
|
+
Deno.exit(failed ? 1 : 0);
|
|
78
|
+
|
|
79
|
+
// the router reads its world out of globalThis and renders through the two functions it is given,
|
|
80
|
+
// so a stub of each is enough to see the page it chose. Only what the first render touches is
|
|
81
|
+
// stubbed here; navigation, scroll restoration and link marking read more of the DOM than this
|
|
82
|
+
async function open(bytes, hash = "") {
|
|
83
|
+
let displayed;
|
|
84
|
+
installEnvironment(hash);
|
|
85
|
+
await router(new Blob([bytes]), {
|
|
86
|
+
extract: (content, { entries, pagePath }) => {
|
|
87
|
+
openedEntries = entries.map(entry => entry.filename).sort();
|
|
88
|
+
return { docContent: "page at " + JSON.stringify(pagePath) };
|
|
89
|
+
},
|
|
90
|
+
display: (document, docContent) => displayed = docContent
|
|
91
|
+
});
|
|
92
|
+
return displayed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function installEnvironment(hash) {
|
|
96
|
+
// the router asks for web workers, which the archive serves from its own extension URL. There
|
|
97
|
+
// is no such URL here, so the request is answered with the synchronous codec instead
|
|
98
|
+
globalThis.zip = { ...zip, configure: options => zip.configure({ ...options, useWebWorkers: false }) };
|
|
99
|
+
globalThis.document = {
|
|
100
|
+
head: { appendChild() { } },
|
|
101
|
+
styleSheets: [],
|
|
102
|
+
createElement: () => ({ setAttribute() { }, remove() { } }),
|
|
103
|
+
querySelectorAll: () => [],
|
|
104
|
+
querySelector: () => null,
|
|
105
|
+
getElementById: () => null
|
|
106
|
+
};
|
|
107
|
+
globalThis.history = {
|
|
108
|
+
state: null,
|
|
109
|
+
scrollRestoration: "auto",
|
|
110
|
+
replaceState(state) {
|
|
111
|
+
this.state = state;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
// Deno defines location as a getter that throws without --location, so it is replaced rather
|
|
115
|
+
// than assigned
|
|
116
|
+
Object.defineProperty(globalThis, "location", {
|
|
117
|
+
value: { href: ARCHIVE_URL + hash, hash },
|
|
118
|
+
configurable: true,
|
|
119
|
+
writable: true
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function makePage(seed, { url, title }) {
|
|
124
|
+
const pageData = makePageData(seed, 2 * 1024);
|
|
125
|
+
pageData.title = title;
|
|
126
|
+
const { bytes } = await runProcess(pageData, makeOptions({ url }));
|
|
127
|
+
return { url, title, getData: async () => bytes };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function packagerOptions(overrides = {}) {
|
|
131
|
+
return {
|
|
132
|
+
selfExtractingArchive: true,
|
|
133
|
+
extractDataFromPage: true,
|
|
134
|
+
zipScript: "/* zip script stub */",
|
|
135
|
+
...overrides
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function check(label, actual, expected) {
|
|
140
|
+
const ok = actual === expected;
|
|
141
|
+
console.log(`${ok ? "PASS" : "FAIL"} ${label}: ${actual}${ok ? "" : " (expected " + expected + ")"}`);
|
|
142
|
+
failed ||= !ok;
|
|
143
|
+
}
|
|
@@ -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);
|