single-file-core 1.5.123 → 1.5.125
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/helper.js +3 -0
- package/core/lib/doctype.js +0 -3
- package/core/lib/processor-helper-common.js +5 -2
- package/core/lib/processor-helper-inline.js +4 -0
- package/core/lib/processor-helper.js +4 -0
- package/core/util.js +8 -8
- package/doc/singlefile-archive.md +48 -17
- package/package.json +2 -2
- package/processors/compression/compression-display.js +3 -1
- package/processors/frame-tree/content/content-frame-tree.js +54 -17
- package/processors/hooks/content/content-hooks-frames-web.js +11 -4
- package/test/sfz-harness/README.md +2 -1
- package/test/sfz-harness/charset-round-trip.js +161 -0
package/core/helper.js
CHANGED
|
@@ -293,6 +293,9 @@ function preProcessDoc(doc, win, options) {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
function markInvalidNesting(doc) {
|
|
296
|
+
if (!doc.body) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
296
299
|
addTrackIds(doc.body);
|
|
297
300
|
const verificationDoc = parseDocContent(serialize(doc));
|
|
298
301
|
const markedMap = buildTrackIdMap(doc.body);
|
package/core/lib/doctype.js
CHANGED
|
@@ -38,9 +38,6 @@ function getDoctypeString(doc) {
|
|
|
38
38
|
} else if (docType.systemId) {
|
|
39
39
|
docTypeString += " SYSTEM \"" + docType.systemId + "\"";
|
|
40
40
|
}
|
|
41
|
-
if (docType.internalSubset) {
|
|
42
|
-
docTypeString += " [" + docType.internalSubset + "]";
|
|
43
|
-
}
|
|
44
41
|
docTypeString += ">";
|
|
45
42
|
}
|
|
46
43
|
return docTypeString;
|
|
@@ -405,7 +405,7 @@ class ProcessorHelperCommon {
|
|
|
405
405
|
}
|
|
406
406
|
sheetIndex++;
|
|
407
407
|
});
|
|
408
|
-
processFontDetails(fontsDetails);
|
|
408
|
+
processFontDetails(fontsDetails, fonts);
|
|
409
409
|
await Promise.all([...stylesheets].map(async ([, stylesheetInfo], sheetIndex) => {
|
|
410
410
|
if (stylesheetInfo.stylesheet) {
|
|
411
411
|
const cssRules = stylesheetInfo.stylesheet.children;
|
|
@@ -449,7 +449,10 @@ class ProcessorHelperCommon {
|
|
|
449
449
|
removedRules.push(cssRule);
|
|
450
450
|
} else {
|
|
451
451
|
fontsDetails.emittedFonts.add(ruleKey);
|
|
452
|
-
await this.processFontFaceRule(ruleData, fontInfo, fonts, fontTests, stats);
|
|
452
|
+
const keptRule = await this.processFontFaceRule(ruleData, fontInfo, fonts, fontTests, stats);
|
|
453
|
+
if (!keptRule) {
|
|
454
|
+
removedRules.push(cssRule);
|
|
455
|
+
}
|
|
453
456
|
}
|
|
454
457
|
} else {
|
|
455
458
|
removedRules.push(cssRule);
|
|
@@ -648,6 +648,9 @@ function getProcessorHelperClass(utilInstance) {
|
|
|
648
648
|
}
|
|
649
649
|
}
|
|
650
650
|
stats.fonts.discarded -= fontInfo.length;
|
|
651
|
+
if (!fontInfo.length) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
651
654
|
fontInfo.reverse();
|
|
652
655
|
try {
|
|
653
656
|
srcDeclaration.data.value = cssTree.parse(fontInfo.map(fontSource => fontSource.src).join(","), { context: "value", parseCustomProperty: true });
|
|
@@ -656,6 +659,7 @@ function getProcessorHelperClass(utilInstance) {
|
|
|
656
659
|
// ignored
|
|
657
660
|
}
|
|
658
661
|
}
|
|
662
|
+
return true;
|
|
659
663
|
}
|
|
660
664
|
};
|
|
661
665
|
}
|
|
@@ -574,6 +574,9 @@ function getProcessorHelperClass(utilInstance) {
|
|
|
574
574
|
removedNodes.forEach(node => ruleData.block.children.remove(node));
|
|
575
575
|
const srcDeclaration = ruleData.block.children.filter(node => node.property == "src").tail;
|
|
576
576
|
if (srcDeclaration) {
|
|
577
|
+
if (!fontInfo.length) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
577
580
|
fontInfo.reverse();
|
|
578
581
|
try {
|
|
579
582
|
srcDeclaration.data.value = cssTree.parse(fontInfo.map(fontSource => fontSource.src).join(","), { context: "value", parseCustomProperty: true });
|
|
@@ -582,6 +585,7 @@ function getProcessorHelperClass(utilInstance) {
|
|
|
582
585
|
// ignored
|
|
583
586
|
}
|
|
584
587
|
}
|
|
588
|
+
return true;
|
|
585
589
|
}
|
|
586
590
|
};
|
|
587
591
|
}
|
package/core/util.js
CHANGED
|
@@ -240,7 +240,7 @@ function getInstance(utilOptions) {
|
|
|
240
240
|
startTime = Date.now();
|
|
241
241
|
log(" // STARTED download url =", resourceURL, "asBinary =", options.asBinary);
|
|
242
242
|
}
|
|
243
|
-
if (options.blockMixedContent && /^https:/i.test(options.baseURI) && !/^https:/i.test(resourceURL)) {
|
|
243
|
+
if (options.blockMixedContent && /^https:/i.test(options.baseURI) && !/^https:/i.test(resourceURL) && !/^blob:https:/i.test(resourceURL)) {
|
|
244
244
|
return getFetchResponse(resourceURL, options);
|
|
245
245
|
}
|
|
246
246
|
if (options.networkTimeout) {
|
|
@@ -390,13 +390,13 @@ function guessMIMEType(expectedType, buffer) {
|
|
|
390
390
|
if (compareBytes([255, 255, 255, 255], [0, 0, 2, 0])) {
|
|
391
391
|
return "image/x-icon";
|
|
392
392
|
}
|
|
393
|
-
if (compareBytes([255, 255], [
|
|
393
|
+
if (compareBytes([255, 255], [66, 77])) {
|
|
394
394
|
return "image/bmp";
|
|
395
395
|
}
|
|
396
396
|
if (compareBytes([255, 255, 255, 255, 255, 255], [71, 73, 70, 56, 57, 97])) {
|
|
397
397
|
return "image/gif";
|
|
398
398
|
}
|
|
399
|
-
if (compareBytes([255, 255, 255, 255, 255, 255], [71, 73, 70, 56,
|
|
399
|
+
if (compareBytes([255, 255, 255, 255, 255, 255], [71, 73, 70, 56, 55, 97])) {
|
|
400
400
|
return "image/gif";
|
|
401
401
|
}
|
|
402
402
|
if (compareBytes([255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255], [82, 73, 70, 70, 0, 0, 0, 0, 87, 69, 66, 80, 86, 80])) {
|
|
@@ -434,7 +434,7 @@ function guessMIMEType(expectedType, buffer) {
|
|
|
434
434
|
if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 105, 115, 111, 109])) {
|
|
435
435
|
return "video/mp4";
|
|
436
436
|
}
|
|
437
|
-
if (compareBytes([255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255], [82, 73, 70, 70, 0, 0, 0, 0,
|
|
437
|
+
if (compareBytes([255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255], [82, 73, 70, 70, 0, 0, 0, 0, 65, 86, 73, 32])) {
|
|
438
438
|
return "video/x-msvideo";
|
|
439
439
|
}
|
|
440
440
|
if (compareBytes([255, 255, 255, 255], [0, 0, 1, 179]) || compareBytes([255, 255, 255, 255], [0, 0, 1, 186])) {
|
|
@@ -443,18 +443,18 @@ function guessMIMEType(expectedType, buffer) {
|
|
|
443
443
|
if (compareBytes([255, 255, 255, 255], [79, 103, 103, 83])) {
|
|
444
444
|
return "video/ogg";
|
|
445
445
|
}
|
|
446
|
-
if (compareBytes([255], [71])) {
|
|
447
|
-
return "video/mp2t";
|
|
448
|
-
}
|
|
449
446
|
if (compareBytes([255, 255, 255, 255], [26, 69, 223, 163])) {
|
|
450
447
|
return "video/webm";
|
|
451
448
|
}
|
|
452
449
|
if (compareBytes([0, 0, 0, 0, 255, 255, 255, 255, 255, 255], [0, 0, 0, 0, 102, 116, 121, 112, 51, 103])) {
|
|
453
450
|
return "video/3gpp";
|
|
454
451
|
}
|
|
452
|
+
if (compareBytes([255], [71])) {
|
|
453
|
+
return "video/mp2t";
|
|
454
|
+
}
|
|
455
455
|
}
|
|
456
456
|
if (expectedType == "audio") {
|
|
457
|
-
if (compareBytes([255, 255], [255, 249]) || compareBytes([255, 255], [255, 254])) {
|
|
457
|
+
if (compareBytes([255, 255], [255, 241]) || compareBytes([255, 255], [255, 249]) || compareBytes([255, 255], [255, 254])) {
|
|
458
458
|
return "audio/aac";
|
|
459
459
|
}
|
|
460
460
|
if (compareBytes([255, 255, 255, 255], [77, 84, 104, 100])) {
|
|
@@ -735,6 +735,23 @@ zip64 locator states an absolute offset in the original file, so it needs the sh
|
|
|
735
735
|
this formula produces and cannot be used to find it. A reader that instead uses the
|
|
736
736
|
sentinels arithmetically gets a shift in the billions, with no diagnostic.
|
|
737
737
|
|
|
738
|
+
Which leaves the record itself to be located without the offset that normally points at
|
|
739
|
+
it. Scan backward from the locator for the `PK\x06\x06` signature and confirm each
|
|
740
|
+
candidate against the record's own size field, the 8 bytes at `p + 4`, which by
|
|
741
|
+
definition excludes the leading 12:
|
|
742
|
+
|
|
743
|
+
```
|
|
744
|
+
p + 12 + size == locatorPosition
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
A well-formed archive puts the record immediately before the locator, where it is 56
|
|
748
|
+
bytes long if it carries no extensible data sector, so `locatorPosition - 56` is worth
|
|
749
|
+
testing before scanning at all. The confirmation matters on the archives that miss:
|
|
750
|
+
past the record the scan walks back through the central directory, whose file names and
|
|
751
|
+
extra fields are arbitrary bytes, and past that through entry data, and a four-byte
|
|
752
|
+
signature turns up in bytes nothing constrains. The test settles each candidate against
|
|
753
|
+
the record's own field, so it needs no offset it does not already have.
|
|
754
|
+
|
|
738
755
|
### 4.6 Text tools
|
|
739
756
|
|
|
740
757
|
The optional text body (`insertTextBody`) addresses one more consumer: software that
|
|
@@ -972,22 +989,24 @@ limit of what the CDATA rung buys here — its value is that real payloads rarel
|
|
|
972
989
|
above states a MUST rather than a quality-of-implementation preference — exhaustion is
|
|
973
990
|
reachable by construction, not only by a payload built to provoke it.
|
|
974
991
|
|
|
975
|
-
The
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
992
|
+
The selection test above — both patterns, on every rung the writer considers — MUST
|
|
993
|
+
be applied to the payload's bytes in their final form, for every payload the writer
|
|
994
|
+
hides and in every variant that hides one. *Final* is the whole of the requirement:
|
|
995
|
+
bytes the writer has yet to settle have not been tested. Every rejection restarts the build (§6):
|
|
996
|
+
the wrapper choice changes the bytes preceding the archive, so the archive must be
|
|
997
|
+
rewritten at its new position.
|
|
998
|
+
|
|
999
|
+
Two fields are patched after that test, and each needs one of its own. The EOCD
|
|
1000
|
+
comment-length field sits at the end of the ZIP region and is patched under the
|
|
1001
|
+
declared form (§6.1, step 11); the writer tests the bytes around it again with the
|
|
1002
|
+
final value in place and keeps the raw form when that value would complete a pattern,
|
|
1003
|
+
since the raw form is always valid. The `tEXt "ZIP"` length field sits inside the pixel-data wrapper,
|
|
1004
|
+
with the fixed `tEXt` type and `ZIP` keyword after it, and is written last (step 12).
|
|
1005
|
+
The header is tested with the rest of the payload, the length as zeros, which cannot
|
|
1006
|
+
join a pattern; the real length is big-endian, so a pattern byte in it would have to be
|
|
1007
|
+
the most significant byte of the chunk's size, and the smallest byte any pattern
|
|
1008
|
+
contains, `-` at 0x2D, puts that size at 0x2D000000 bytes, about 755 MB. The writer
|
|
1009
|
+
refuses to build a self-extracting PNG variant whose chunk reaches that size rather than
|
|
991
1010
|
re-check the field.
|
|
992
1011
|
|
|
993
1012
|
### 5.2 The appended-data budget
|
|
@@ -1043,6 +1062,15 @@ self-consistent:
|
|
|
1043
1062
|
file positions (§4.2), so a reader of the *whole file* never needs prepended-data
|
|
1044
1063
|
compensation — the repair by which a reader recomputes offsets that disagree with
|
|
1045
1064
|
the file size. A reader of the recovered ZIP region alone does need it (§4.5).
|
|
1065
|
+
|
|
1066
|
+
The alternative, offsets relative to the start of the region, is not a compatibility
|
|
1067
|
+
problem in itself: a reader that compensates arrives at the same entries, and 7-Zip
|
|
1068
|
+
opens such a file when told the type. What absolute offsets buy is the step before
|
|
1069
|
+
that. The file is a valid archive read as it stands, so it survives format
|
|
1070
|
+
auto-detection — 7-Zip reports a base of 0 and a physical size covering the whole
|
|
1071
|
+
file — and the compensation is confined to the one path that cannot avoid it,
|
|
1072
|
+
universal-mode recovery. Nothing in the format depends on the choice; a writer using
|
|
1073
|
+
the other form produces files this document's readers still open.
|
|
1046
1074
|
- **PDF offsets are header-relative.** The document's own cross-reference offsets are
|
|
1047
1075
|
interpreted from the `%PDF-` header, so embedding it needs no rewriting; the writer
|
|
1048
1076
|
only MUST keep the header inside the scan window (§4.3).
|
|
@@ -1766,7 +1794,10 @@ of §5.7.
|
|
|
1766
1794
|
|
|
1767
1795
|
### 8.4 The charset round trip, measured
|
|
1768
1796
|
|
|
1769
|
-
Two claims of §2.1 were verified.
|
|
1797
|
+
Two claims of §2.1 were verified. The first is re-derived on every run by
|
|
1798
|
+
`test/sfz-harness/charset-round-trip.js`, which reads the tables below out of the
|
|
1799
|
+
runtime's own decoders rather than trusting this section, and checks the reverse table
|
|
1800
|
+
the extractor ships against the one the rule of §5.5 produces.
|
|
1770
1801
|
|
|
1771
1802
|
**Which encodings qualify.** Decoding all 256 byte values through each encoding
|
|
1772
1803
|
defined by the WHATWG standard shows 20 that are injective and never produce U+FFFD:
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "single-file-core",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.125",
|
|
4
4
|
"description": "SingleFile Core",
|
|
5
5
|
"author": "Gildas Lormeau",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js",
|
|
8
|
+
"test": "deno run --allow-read test/sfz-harness/format-rules.js && deno run --allow-read test/sfz-harness/stored-trigger.js && deno run --allow-read test/sfz-harness/check-determinism.js && deno run --allow-read test/sfz-harness/option-wiring.js && deno run --allow-read test/sfz-harness/css-property-filter.js && deno run --allow-read test/sfz-harness/adopted-stylesheets-hook.js && deno run --allow-read test/sfz-harness/css-fonts-minifier.js && deno run --allow-read test/sfz-harness/inlined-functions.js && deno run --allow-read test/sfz-harness/pages-archive.js && deno run --allow-read test/sfz-harness/filename-max-length.js && deno run --allow-read test/sfz-harness/entry-compression.js && deno run --allow-read test/sfz-harness/filename-characters.js && deno run --allow-read test/sfz-harness/byte-map.js && deno run --allow-read test/sfz-harness/zip64.js && deno run --allow-read test/sfz-harness/charset-round-trip.js",
|
|
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",
|
|
@@ -54,7 +54,9 @@ async function display(document, docContent, { disableFramePointerEvents, inPlac
|
|
|
54
54
|
if (disableFramePointerEvents) {
|
|
55
55
|
doc.querySelectorAll("iframe").forEach(element => {
|
|
56
56
|
const pointerEvents = "pointer-events";
|
|
57
|
-
|
|
57
|
+
if (element.style.getPropertyValue(pointerEvents) != "none" || element.style.getPropertyPriority(pointerEvents) != "important") {
|
|
58
|
+
element.style.setProperty("--sf-" + pointerEvents, element.style.getPropertyValue(pointerEvents), element.style.getPropertyPriority(pointerEvents));
|
|
59
|
+
}
|
|
58
60
|
element.style.setProperty(pointerEvents, "none", "important");
|
|
59
61
|
});
|
|
60
62
|
}
|
|
@@ -177,7 +177,7 @@ function initRequestSync(message) {
|
|
|
177
177
|
if (!TOP_WINDOW) {
|
|
178
178
|
windowId = globalThis.frameId = message.windowId;
|
|
179
179
|
}
|
|
180
|
-
processFrames(document, message.options, windowId, sessionId);
|
|
180
|
+
processFrames(document, message.options, windowId, sessionId, false);
|
|
181
181
|
if (!TOP_WINDOW) {
|
|
182
182
|
sendInitResponse({ frames: [getFrameData(document, globalThis, windowId, message.options, message.scrolling)], sessionId, requestedFrameId: document.documentElement.dataset.requestedFrameId && windowId });
|
|
183
183
|
delete document.documentElement.dataset.requestedFrameId;
|
|
@@ -190,7 +190,7 @@ async function initRequestAsync(message) {
|
|
|
190
190
|
if (!TOP_WINDOW) {
|
|
191
191
|
windowId = globalThis.frameId = message.windowId;
|
|
192
192
|
}
|
|
193
|
-
processFrames(document, message.options, windowId, sessionId);
|
|
193
|
+
processFrames(document, message.options, windowId, sessionId, message.waitForFrames !== false);
|
|
194
194
|
if (!TOP_WINDOW) {
|
|
195
195
|
sendInitResponse({ frames: [getFrameData(document, globalThis, windowId, message.options, message.scrolling)], sessionId, requestedFrameId: document.documentElement.dataset.requestedFrameId && windowId });
|
|
196
196
|
delete document.documentElement.dataset.requestedFrameId;
|
|
@@ -254,15 +254,15 @@ function initResponse(message) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
-
function processFrames(doc, options, parentWindowId, sessionId) {
|
|
257
|
+
function processFrames(doc, options, parentWindowId, sessionId, waitForFrames) {
|
|
258
258
|
const frameElements = getFrames(doc);
|
|
259
|
-
processFramesAsync(doc, frameElements, options, parentWindowId, sessionId);
|
|
259
|
+
processFramesAsync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames);
|
|
260
260
|
if (frameElements.length) {
|
|
261
|
-
processFramesSync(doc, frameElements, options, parentWindowId, sessionId);
|
|
261
|
+
processFramesSync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames);
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
function processFramesAsync(doc, frameElements, options, parentWindowId, sessionId) {
|
|
265
|
+
function processFramesAsync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames) {
|
|
266
266
|
const frames = [];
|
|
267
267
|
let requestTimeouts;
|
|
268
268
|
if (sessions.get(sessionId)) {
|
|
@@ -280,17 +280,18 @@ function processFramesAsync(doc, frameElements, options, parentWindowId, session
|
|
|
280
280
|
frameElements.forEach((frameElement, frameIndex) => {
|
|
281
281
|
const windowId = parentWindowId + WINDOW_ID_SEPARATOR + frameIndex;
|
|
282
282
|
try {
|
|
283
|
-
sendMessage(frameElement.contentWindow, { method: INIT_REQUEST_MESSAGE, windowId, sessionId, options, scrolling: frameElement.scrolling });
|
|
283
|
+
sendMessage(frameElement.contentWindow, { method: INIT_REQUEST_MESSAGE, windowId, sessionId, options, scrolling: frameElement.scrolling, waitForFrames });
|
|
284
284
|
// eslint-disable-next-line no-unused-vars
|
|
285
285
|
} catch (error) {
|
|
286
286
|
// ignored
|
|
287
287
|
}
|
|
288
|
-
|
|
288
|
+
setFrameFallback(sessionId, windowId, () => getSrcdocFrameData(frameElement, windowId, options, sessionId));
|
|
289
|
+
requestTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [getFrameFallback(sessionId, windowId) || { windowId, processed: true }], sessionId }), TIMEOUT_INIT_REQUEST_MESSAGE);
|
|
289
290
|
});
|
|
290
291
|
delete doc.documentElement.dataset.requestedFrameId;
|
|
291
292
|
}
|
|
292
293
|
|
|
293
|
-
function processFramesSync(doc, frameElements, options, parentWindowId, sessionId) {
|
|
294
|
+
function processFramesSync(doc, frameElements, options, parentWindowId, sessionId, waitForFrames) {
|
|
294
295
|
const frames = [];
|
|
295
296
|
frameElements.forEach((frameElement, frameIndex) => {
|
|
296
297
|
const windowId = parentWindowId + WINDOW_ID_SEPARATOR + frameIndex;
|
|
@@ -303,21 +304,25 @@ function processFramesSync(doc, frameElements, options, parentWindowId, sessionI
|
|
|
303
304
|
} catch (error) {
|
|
304
305
|
// ignored
|
|
305
306
|
}
|
|
306
|
-
const srcdoc = frameElement.getAttribute("srcdoc");
|
|
307
|
-
if (!frameDoc && srcdoc) {
|
|
308
|
-
const doc = new DOMParser().parseFromString(srcdoc, "text/html");
|
|
309
|
-
frameDoc = doc;
|
|
310
|
-
frameWindow = globalThis;
|
|
311
|
-
}
|
|
312
307
|
if (frameDoc) {
|
|
313
308
|
try {
|
|
314
309
|
clearFrameTimeout("requestTimeouts", sessionId, windowId);
|
|
315
|
-
processFrames(frameDoc, options, windowId, sessionId);
|
|
310
|
+
processFrames(frameDoc, options, windowId, sessionId, waitForFrames);
|
|
316
311
|
frames.push(getFrameData(frameDoc, frameWindow, windowId, options, frameElement.scrolling));
|
|
317
312
|
// eslint-disable-next-line no-unused-vars
|
|
318
313
|
} catch (error) {
|
|
319
314
|
frames.push({ windowId, processed: true });
|
|
320
315
|
}
|
|
316
|
+
} else if (!waitForFrames) {
|
|
317
|
+
// the frame is cross-origin or sandboxed, so its document is out of reach. Re-parsing
|
|
318
|
+
// srcdoc is the only source left here, and it is markup only: no script has run and
|
|
319
|
+
// nothing is rendered. When there is time to wait, the fallback is kept for the frames
|
|
320
|
+
// that never answer instead, so a frame that does answer wins with its rendered data
|
|
321
|
+
const fallbackFrameData = getFrameFallback(sessionId, windowId);
|
|
322
|
+
if (fallbackFrameData) {
|
|
323
|
+
clearFrameTimeout("requestTimeouts", sessionId, windowId);
|
|
324
|
+
frames.push(fallbackFrameData);
|
|
325
|
+
}
|
|
321
326
|
}
|
|
322
327
|
});
|
|
323
328
|
sendInitResponse({ frames, sessionId, requestedFrameId: doc.documentElement.dataset.requestedFrameId && parentWindowId });
|
|
@@ -338,7 +343,39 @@ function clearFrameTimeout(type, sessionId, windowId) {
|
|
|
338
343
|
function createFrameResponseTimeout(sessionId, windowId) {
|
|
339
344
|
const session = sessions.get(sessionId);
|
|
340
345
|
if (session && session.responseTimeouts) {
|
|
341
|
-
session.responseTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [
|
|
346
|
+
session.responseTimeouts[windowId] = globalThis.setTimeout(() => sendInitResponse({ frames: [getFrameFallback(sessionId, windowId) || { windowId, processed: true }], sessionId: sessionId }), TIMEOUT_INIT_RESPONSE_MESSAGE);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function setFrameFallback(sessionId, windowId, getFallbackFrameData) {
|
|
351
|
+
const session = sessions.get(sessionId);
|
|
352
|
+
if (session) {
|
|
353
|
+
if (!session.frameFallbacks) {
|
|
354
|
+
session.frameFallbacks = {};
|
|
355
|
+
}
|
|
356
|
+
session.frameFallbacks[windowId] = getFallbackFrameData;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function getFrameFallback(sessionId, windowId) {
|
|
361
|
+
const session = sessions.get(sessionId);
|
|
362
|
+
const getFallbackFrameData = session && session.frameFallbacks && session.frameFallbacks[windowId];
|
|
363
|
+
if (getFallbackFrameData) {
|
|
364
|
+
return getFallbackFrameData();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getSrcdocFrameData(frameElement, windowId, options, sessionId) {
|
|
369
|
+
const srcdoc = frameElement.getAttribute("srcdoc");
|
|
370
|
+
if (srcdoc) {
|
|
371
|
+
try {
|
|
372
|
+
const frameDoc = new DOMParser().parseFromString(srcdoc, "text/html");
|
|
373
|
+
processFrames(frameDoc, options, windowId, sessionId, false);
|
|
374
|
+
return getFrameData(frameDoc, globalThis, windowId, options, frameElement.scrolling);
|
|
375
|
+
// eslint-disable-next-line no-unused-vars
|
|
376
|
+
} catch (error) {
|
|
377
|
+
// ignored
|
|
378
|
+
}
|
|
342
379
|
}
|
|
343
380
|
}
|
|
344
381
|
|
|
@@ -412,6 +412,7 @@
|
|
|
412
412
|
}
|
|
413
413
|
};
|
|
414
414
|
document.fonts.delete.toString = function () { return "function delete() { [native code] }"; };
|
|
415
|
+
setFunctionName(document.fonts.delete, "delete");
|
|
415
416
|
const clearFonts = document.fonts.clear;
|
|
416
417
|
document.fonts.clear = function () {
|
|
417
418
|
try {
|
|
@@ -423,16 +424,16 @@
|
|
|
423
424
|
}
|
|
424
425
|
};
|
|
425
426
|
document.fonts.clear.toString = function () { return "function clear() { [native code] }"; };
|
|
427
|
+
setFunctionName(document.fonts.clear, "clear");
|
|
426
428
|
}
|
|
427
429
|
|
|
428
430
|
if (globalThis.IntersectionObserver) {
|
|
429
431
|
const origIntersectionObserver = globalThis.IntersectionObserver;
|
|
430
|
-
globalThis.IntersectionObserver = function IntersectionObserver() {
|
|
432
|
+
globalThis.IntersectionObserver = function IntersectionObserver(callback) {
|
|
431
433
|
try {
|
|
432
434
|
const intersectionObserver = new origIntersectionObserver(...arguments);
|
|
433
435
|
const observeIntersection = origIntersectionObserver.prototype.observe || intersectionObserver.observe;
|
|
434
436
|
const unobserveIntersection = origIntersectionObserver.prototype.unobserve || intersectionObserver.unobserve;
|
|
435
|
-
const callback = arguments[0];
|
|
436
437
|
const options = arguments[1];
|
|
437
438
|
if (observeIntersection) {
|
|
438
439
|
intersectionObserver.observe = function (targetElement) {
|
|
@@ -450,6 +451,7 @@
|
|
|
450
451
|
}
|
|
451
452
|
};
|
|
452
453
|
intersectionObserver.observe.toString = function () { return "function observe() { [native code] }"; };
|
|
454
|
+
setFunctionName(intersectionObserver.observe, "observe");
|
|
453
455
|
}
|
|
454
456
|
if (unobserveIntersection) {
|
|
455
457
|
intersectionObserver.unobserve = function (targetElement) {
|
|
@@ -471,6 +473,7 @@
|
|
|
471
473
|
}
|
|
472
474
|
};
|
|
473
475
|
intersectionObserver.unobserve.toString = function () { return "function unobserve() { [native code] }"; };
|
|
476
|
+
setFunctionName(intersectionObserver.unobserve, "unobserve");
|
|
474
477
|
}
|
|
475
478
|
observers.set(intersectionObserver, { callback, options });
|
|
476
479
|
return intersectionObserver;
|
|
@@ -496,6 +499,7 @@
|
|
|
496
499
|
}
|
|
497
500
|
};
|
|
498
501
|
CSSStyleSheet.prototype.replaceSync.toString = function () { return "function replaceSync() { [native code] }"; };
|
|
502
|
+
setFunctionName(CSSStyleSheet.prototype.replaceSync, "replaceSync");
|
|
499
503
|
const orginalReplace = CSSStyleSheet.prototype.replace;
|
|
500
504
|
CSSStyleSheet.prototype.replace = async function (text) {
|
|
501
505
|
try {
|
|
@@ -508,10 +512,11 @@
|
|
|
508
512
|
}
|
|
509
513
|
};
|
|
510
514
|
CSSStyleSheet.prototype.replace.toString = function () { return "function replace() { [native code] }"; };
|
|
515
|
+
setFunctionName(CSSStyleSheet.prototype.replace, "replace");
|
|
511
516
|
const originalInsertRule = CSSStyleSheet.prototype.insertRule;
|
|
512
|
-
CSSStyleSheet.prototype.insertRule = function (rule
|
|
517
|
+
CSSStyleSheet.prototype.insertRule = function (rule) {
|
|
513
518
|
try {
|
|
514
|
-
const result = originalInsertRule.apply(this, [rule,
|
|
519
|
+
const result = originalInsertRule.apply(this, [rule, arguments[1]]);
|
|
515
520
|
adoptedStylesheetsData.delete(this);
|
|
516
521
|
return result;
|
|
517
522
|
} catch (error) {
|
|
@@ -520,6 +525,7 @@
|
|
|
520
525
|
}
|
|
521
526
|
};
|
|
522
527
|
CSSStyleSheet.prototype.insertRule.toString = function () { return "function insertRule() { [native code] }"; };
|
|
528
|
+
setFunctionName(CSSStyleSheet.prototype.insertRule, "insertRule");
|
|
523
529
|
const originalDeleteRule = CSSStyleSheet.prototype.deleteRule;
|
|
524
530
|
CSSStyleSheet.prototype.deleteRule = function (index) {
|
|
525
531
|
try {
|
|
@@ -532,6 +538,7 @@
|
|
|
532
538
|
}
|
|
533
539
|
};
|
|
534
540
|
CSSStyleSheet.prototype.deleteRule.toString = function () { return "function deleteRule() { [native code] }"; };
|
|
541
|
+
setFunctionName(CSSStyleSheet.prototype.deleteRule, "deleteRule");
|
|
535
542
|
|
|
536
543
|
// the listener below is reached through the host element, and a closed shadow root is
|
|
537
544
|
// not reachable from it, so the roots are recorded as they are created
|
|
@@ -27,7 +27,7 @@ any check failed.
|
|
|
27
27
|
|
|
28
28
|
| Script | What it covers |
|
|
29
29
|
|---|---|
|
|
30
|
-
| `format-rules.js` | The rules of the format: charset
|
|
30
|
+
| `format-rules.js` | The rules of the format: the charset declaration and the doctype cap that keeps it inside the scan window, the wrapper-tag ladder and its selection tests, the identifier, appended-data placement and declaration, password scope, the PDF and PNG faces. |
|
|
31
31
|
| `stored-trigger.js` | That a stored (uncompressed) entry whose bytes contain a rung's pattern moves the writer to the right rung. |
|
|
32
32
|
| `check-determinism.js` | That the same inputs produce the same bytes, and that the levers which should change the output do. |
|
|
33
33
|
| `option-wiring.js` | That every option `compression.js` reads is either declared as a caller option or classified as internal, and that `single-file.js` still builds its argument from that declaration. Guards the layer the other suites sit below. |
|
|
@@ -40,6 +40,7 @@ any check failed.
|
|
|
40
40
|
| `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
41
|
| `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
42
|
| `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. |
|
|
43
|
+
| `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. |
|
|
43
44
|
| `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. |
|
|
44
45
|
|
|
45
46
|
## The tools
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2010-2026 Gildas Lormeau
|
|
3
|
+
* contact : gildas.lormeau <at> gmail.com
|
|
4
|
+
*
|
|
5
|
+
* This file is part of SingleFile.
|
|
6
|
+
*
|
|
7
|
+
* The code in this file is free software: you can redistribute it and/or
|
|
8
|
+
* modify it under the terms of the GNU Affero General Public License
|
|
9
|
+
* (GNU AGPL) as published by the Free Software Foundation, either version 3
|
|
10
|
+
* of the License, or (at your option) any later version.
|
|
11
|
+
*
|
|
12
|
+
* The code in this file is distributed in the hope that it will be useful,
|
|
13
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
|
15
|
+
* General Public License for more details.
|
|
16
|
+
*
|
|
17
|
+
* As additional permission under GNU AGPL version 3 section 7, you may
|
|
18
|
+
* distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
|
|
19
|
+
* AGPL normally required by section 4, provided you include this license
|
|
20
|
+
* notice and a URL through which recipients can access the Corresponding
|
|
21
|
+
* Source.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Universal mode recovers the ZIP region from the characters the HTML parser produced, so the
|
|
25
|
+
// declared charset has to carry all 256 byte values through a decode injectively (§2.1). Which
|
|
26
|
+
// encodings do is a property of the WHATWG index, not of this repository, and §8.4 prints the
|
|
27
|
+
// answer as a table: 20 qualify, 18 do not, and each qualifying one needs a reverse table of a
|
|
28
|
+
// stated size. Nothing re-derived that table -- it was measured once, by hand, outside the repo,
|
|
29
|
+
// and would go stale silently if an index changed or the prose were edited.
|
|
30
|
+
//
|
|
31
|
+
// The last check is the one with teeth. §5.5 requires the reverse table to be derived from the
|
|
32
|
+
// WHATWG index and NOT from a platform codec of the same name, because most platform codecs
|
|
33
|
+
// leave five windows-1252 positions undefined and those bytes occur in ordinary compressed data.
|
|
34
|
+
// The extractor ships that table as a literal, so it is derived once at authoring time and never
|
|
35
|
+
// again; here it is re-derived from the runtime's own decoder and compared entry by entry.
|
|
36
|
+
|
|
37
|
+
const BYTES = new Uint8Array(256).map((_, index) => index);
|
|
38
|
+
|
|
39
|
+
// the 20 of §8.4, in the order the section lists them
|
|
40
|
+
const QUALIFYING = [
|
|
41
|
+
"windows-1252", "iso-8859-2", "iso-8859-4", "iso-8859-5", "iso-8859-10", "iso-8859-13",
|
|
42
|
+
"iso-8859-14", "iso-8859-15", "iso-8859-16", "koi8-r", "koi8-u", "macintosh", "windows-1250",
|
|
43
|
+
"windows-1251", "windows-1254", "windows-1256", "windows-1258", "x-mac-cyrillic", "ibm866",
|
|
44
|
+
"x-user-defined"
|
|
45
|
+
];
|
|
46
|
+
// the 18 that do not: eight single-byte encodings with undefined positions in their index, then
|
|
47
|
+
// the multi-byte ones, which decode a lone byte sequence to U+FFFD or to fewer than 256 characters
|
|
48
|
+
const DISQUALIFIED = [
|
|
49
|
+
"iso-8859-3", "iso-8859-6", "iso-8859-7", "iso-8859-8", "windows-874", "windows-1253",
|
|
50
|
+
"windows-1255", "windows-1257",
|
|
51
|
+
"utf-8", "utf-16le", "utf-16be", "gbk", "gb18030", "big5", "euc-jp", "shift_jis", "euc-kr",
|
|
52
|
+
"iso-2022-jp"
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
let failures = 0;
|
|
56
|
+
|
|
57
|
+
function describe(label) {
|
|
58
|
+
const points = Array.from(new TextDecoder(label).decode(BYTES));
|
|
59
|
+
if (points.length != 256) {
|
|
60
|
+
return { qualifies: false, reason: points.length + " characters" };
|
|
61
|
+
}
|
|
62
|
+
const table = new Map();
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
let identity = 0;
|
|
65
|
+
for (let byte = 0; byte < 256; byte++) {
|
|
66
|
+
const codePoint = points[byte].codePointAt(0);
|
|
67
|
+
if (codePoint == 0xFFFD) {
|
|
68
|
+
return { qualifies: false, reason: "U+FFFD at 0x" + byte.toString(16) };
|
|
69
|
+
}
|
|
70
|
+
if (seen.has(codePoint)) {
|
|
71
|
+
return { qualifies: false, reason: "collision at 0x" + byte.toString(16) };
|
|
72
|
+
}
|
|
73
|
+
seen.add(codePoint);
|
|
74
|
+
if (codePoint == byte) {
|
|
75
|
+
identity++;
|
|
76
|
+
} else {
|
|
77
|
+
table.set(codePoint, byte);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { qualifies: true, identity, table, points };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const described = new Map([...QUALIFYING, ...DISQUALIFIED].map(label => [label, describe(label)]));
|
|
84
|
+
|
|
85
|
+
check("§8.4 covers the whole standard: 20 qualifying + 18 disqualified",
|
|
86
|
+
QUALIFYING.length + DISQUALIFIED.length == 38 && new Set([...QUALIFYING, ...DISQUALIFIED]).size == 38);
|
|
87
|
+
|
|
88
|
+
const wrongVerdict = [...described].filter(([label, result]) =>
|
|
89
|
+
result.qualifies != QUALIFYING.includes(label));
|
|
90
|
+
check("every encoding falls on the side of the table §8.4 puts it on", wrongVerdict.length == 0,
|
|
91
|
+
wrongVerdict.map(([label, result]) => label + " " + (result.reason || "qualifies")).join(", "));
|
|
92
|
+
|
|
93
|
+
// §8.4 quotes the extremes of the reverse-table sizes; they bound what an implementation has to
|
|
94
|
+
// carry to support any qualifying charset rather than only the one the reference writer declares
|
|
95
|
+
const qualifying = QUALIFYING.filter(label => described.get(label).qualifies);
|
|
96
|
+
const sizes = qualifying.map(label => [label, described.get(label).table.size]);
|
|
97
|
+
const smallest = Math.min(...sizes.map(([, size]) => size));
|
|
98
|
+
const largest = Math.max(...sizes.map(([, size]) => size));
|
|
99
|
+
check("the smallest reverse table is 8 entries, iso-8859-15", smallest == 8 &&
|
|
100
|
+
sizes.filter(([, size]) => size == smallest).map(([label]) => label).join() == "iso-8859-15");
|
|
101
|
+
check("the largest is 128, for koi8-r, koi8-u, ibm866 and x-user-defined", largest == 128 &&
|
|
102
|
+
sizes.filter(([, size]) => size == largest).map(([label]) => label).sort().join() ==
|
|
103
|
+
"ibm866,koi8-r,koi8-u,x-user-defined");
|
|
104
|
+
|
|
105
|
+
const windows1252 = described.get("windows-1252");
|
|
106
|
+
check("windows-1252 decodes 229 of the 256 values to themselves (§5.5 rule 1)",
|
|
107
|
+
windows1252.identity == 229, String(windows1252.identity));
|
|
108
|
+
check("its reverse table is the remaining 27 (§5.5 rule 2)", windows1252.table.size == 27,
|
|
109
|
+
String(windows1252.table.size));
|
|
110
|
+
|
|
111
|
+
// the trap of §5.5: iso-8859-1 is a LABEL of windows-1252, not the identity mapping its name
|
|
112
|
+
// suggests, so a reader that treats it as latin-1 builds a table with no entries at all
|
|
113
|
+
check("iso-8859-1 is a label of windows-1252, not a separate identity encoding",
|
|
114
|
+
new TextDecoder("iso-8859-1").encoding == "windows-1252" &&
|
|
115
|
+
new TextDecoder("latin1").encoding == "windows-1252");
|
|
116
|
+
|
|
117
|
+
// the five positions of the §5.5 table: the WHATWG index assigns them, most platform codecs do not
|
|
118
|
+
check("the WHATWG index assigns 0x81, 0x8D, 0x8F, 0x90 and 0x9D (§5.5)",
|
|
119
|
+
[0x81, 0x8D, 0x8F, 0x90, 0x9D].every(byte =>
|
|
120
|
+
windows1252.points[byte].codePointAt(0) == byte && !windows1252.table.has(byte)));
|
|
121
|
+
|
|
122
|
+
// §8.4's caveat on x-user-defined: it qualifies on the criterion and is still a poor choice
|
|
123
|
+
check("x-user-defined maps 0x80-0xFF into the Private Use Area, U+F780-U+F7FF",
|
|
124
|
+
[...Array(128).keys()].every(index =>
|
|
125
|
+
described.get("x-user-defined").points[128 + index].codePointAt(0) == 0xF780 + index));
|
|
126
|
+
|
|
127
|
+
// the round trip of §5.5 rules 1 and 2, on every byte value and every qualifying charset. NUL and
|
|
128
|
+
// the newlines need rules 3 and 4 in a browser, where the parser has replaced and normalized them;
|
|
129
|
+
// through a decoder alone they arrive intact, so the mapping is exact for all 256 values here
|
|
130
|
+
const broken = qualifying.filter(label => {
|
|
131
|
+
const { table, points } = described.get(label);
|
|
132
|
+
return points.some((character, byte) => {
|
|
133
|
+
const codePoint = character.codePointAt(0);
|
|
134
|
+
return (table.has(codePoint) ? table.get(codePoint) : codePoint) != byte;
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
check("all 256 byte values survive decode and reverse mapping, under every qualifying charset",
|
|
138
|
+
broken.length == 0, broken.join(", "));
|
|
139
|
+
|
|
140
|
+
// the extractor's own table, re-derived. It is inlined into every archive, so an error here is
|
|
141
|
+
// not caught by any build step and corrupts one byte per occurrence in the recovered region
|
|
142
|
+
const compression = await Deno.readTextFile(new URL("../../processors/compression/compression.js", import.meta.url));
|
|
143
|
+
const literal = compression.slice(compression.indexOf("const characterMap = new Map(["));
|
|
144
|
+
const shipped = new Map([...literal.slice(0, literal.indexOf("]);")).matchAll(/\[(\d+),\s*(\d+)\]/g)]
|
|
145
|
+
.map(([, codePoint, byte]) => [Number(codePoint), Number(byte)]));
|
|
146
|
+
const expected = new Map([[0xFFFD, 0], ...windows1252.table]);
|
|
147
|
+
const wrongEntries = [...expected].filter(([codePoint, byte]) => shipped.get(codePoint) !== byte);
|
|
148
|
+
const extraEntries = [...shipped].filter(([codePoint]) => !expected.has(codePoint));
|
|
149
|
+
check("the extractor's characterMap is the derived windows-1252 table plus U+FFFD (28 entries)",
|
|
150
|
+
shipped.size == 28 && wrongEntries.length == 0 && extraEntries.length == 0,
|
|
151
|
+
"missing/wrong " + JSON.stringify(wrongEntries) + " extra " + JSON.stringify(extraEntries));
|
|
152
|
+
|
|
153
|
+
console.log(failures ? `\n${failures} check(s) FAILED` : "\nall checks passed");
|
|
154
|
+
Deno.exit(failures ? 1 : 0);
|
|
155
|
+
|
|
156
|
+
function check(label, condition, detail) {
|
|
157
|
+
if (!condition) {
|
|
158
|
+
failures++;
|
|
159
|
+
}
|
|
160
|
+
console.log((condition ? "PASS" : "FAIL") + " " + label + (condition || !detail ? "" : ": " + detail));
|
|
161
|
+
}
|