single-file-cli 2.9.1 → 2.10.0
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/build.sh +1 -1
- package/deno.json +1 -1
- package/lib/bidi-client.js +15 -5
- package/lib/cdp-client.js +12 -2
- package/lib/single-file-archive.js +4 -4
- package/lib/single-file-bundle.js +1 -1
- package/lib/single-file-script.js +12 -6
- package/lib/version.js +1 -1
- package/options.js +3 -2
- package/package.json +1 -1
- package/single-file-cli-api.js +22 -22
- package/test/e2e/browser-script.test.js +112 -0
- package/test/e2e/canvas-round-trip.test.js +5 -1
- package/test/e2e/font-face-source-order.test.js +118 -0
- package/test/e2e/html-at-media-url.test.js +3 -1
- package/test/e2e/max-appended-data-length.test.js +100 -0
- package/test/unit/archive-options.test.js +51 -0
|
@@ -29,6 +29,8 @@ import { Deno } from "./deno-polyfill.js";
|
|
|
29
29
|
const FETCH_FUNCTION_NAME = "__singleFileFetch";
|
|
30
30
|
const RESOLVE_FETCH_FUNCTION_NAME = "__singleFileResolveFetch";
|
|
31
31
|
const REJECT_FETCH_FUNCTION_NAME = "__singleFileRejectFetch";
|
|
32
|
+
const SCRIPT_READY_PROPERTY_NAME = "__singleFileScriptReady";
|
|
33
|
+
const SCRIPT_ERROR_PROPERTY_NAME = "__singleFileScriptError";
|
|
32
34
|
|
|
33
35
|
const { readTextFile } = Deno;
|
|
34
36
|
|
|
@@ -36,6 +38,8 @@ export {
|
|
|
36
38
|
FETCH_FUNCTION_NAME,
|
|
37
39
|
RESOLVE_FETCH_FUNCTION_NAME,
|
|
38
40
|
REJECT_FETCH_FUNCTION_NAME,
|
|
41
|
+
SCRIPT_READY_PROPERTY_NAME,
|
|
42
|
+
SCRIPT_ERROR_PROPERTY_NAME,
|
|
39
43
|
getScriptSource,
|
|
40
44
|
getHookScriptSource,
|
|
41
45
|
getZipScriptSource,
|
|
@@ -89,19 +93,21 @@ function initSingleFile(constants) {
|
|
|
89
93
|
});
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
async function getScriptSource(options) {
|
|
96
|
+
async function getScriptSource(options, globalAssignment = "") {
|
|
93
97
|
let source = "";
|
|
94
98
|
source += script;
|
|
95
|
-
source +=
|
|
99
|
+
source += "\n" + globalAssignment;
|
|
100
|
+
source += "\n" + await readScriptFiles(options && options.browserScripts ? options.browserScripts : []);
|
|
96
101
|
if (options.browserStylesheets && options.browserStylesheets.length) {
|
|
97
|
-
source += "
|
|
102
|
+
source += "\naddEventListener(\"load\",()=>{const styleElement=document.createElement(\"style\");styleElement.textContent=" + JSON.stringify(await readScriptFiles(options.browserStylesheets)) + ";document.body.appendChild(styleElement);});";
|
|
98
103
|
}
|
|
99
|
-
source += "(" + initSingleFile.toString() + ")(" + JSON.stringify({
|
|
104
|
+
source += "\n(" + initSingleFile.toString() + ")(" + JSON.stringify({
|
|
100
105
|
FETCH_FUNCTION_NAME,
|
|
101
106
|
RESOLVE_FETCH_FUNCTION_NAME,
|
|
102
107
|
REJECT_FETCH_FUNCTION_NAME
|
|
103
108
|
}) + ");";
|
|
104
|
-
return source;
|
|
109
|
+
return "try{" + source + "\nglobalThis." + SCRIPT_READY_PROPERTY_NAME + "=true;}" +
|
|
110
|
+
"catch(error){globalThis." + SCRIPT_ERROR_PROPERTY_NAME + "=String(error);throw error;}";
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
function getHookScriptSource() {
|
|
@@ -156,5 +162,5 @@ function getPageDataScriptSource(options, [SET_SCREENSHOT_FUNCTION_NAME, SET_PDF
|
|
|
156
162
|
|
|
157
163
|
|
|
158
164
|
async function readScriptFiles(paths) {
|
|
159
|
-
return (await Promise.all(paths.map(path => readTextFile(path)))).join("");
|
|
165
|
+
return (await Promise.all(paths.map(path => readTextFile(path)))).join("\n");
|
|
160
166
|
}
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.
|
|
1
|
+
export const version = "2.10.0";
|
package/options.js
CHANGED
|
@@ -104,7 +104,7 @@ const OPTIONS_INFO = [{
|
|
|
104
104
|
"block-scripts": { description: "Block scripts", type: "boolean", defaultValue: true },
|
|
105
105
|
"block-stylesheets": { description: "Block stylesheets", type: "boolean", defaultValue: false },
|
|
106
106
|
"block-videos": { description: "Block videos", type: "boolean", defaultValue: true },
|
|
107
|
-
"block-mixed-content": { description: "Block
|
|
107
|
+
"block-mixed-content": { description: "Block active content (i.e. stylesheets, scripts, fonts) served from HTTP in HTTPS pages, like browsers do; images, videos and audios are unaffected", type: "boolean" },
|
|
108
108
|
"blocked-url-pattern": { key: "blockedURLPatterns", description: "Regular expression matching URLs to block (e.g. 'annoying-banners\\.com')", type: "string[]" }
|
|
109
109
|
}, {
|
|
110
110
|
"load-deferred-images": { description: "Load deferred (a.k.a. lazy-loaded) images", type: "boolean", defaultValue: true },
|
|
@@ -113,7 +113,7 @@ const OPTIONS_INFO = [{
|
|
|
113
113
|
"load-deferred-images-keep-zoom-level": { description: "Load deferred images by keeping zoomed out the page", type: "boolean" },
|
|
114
114
|
"load-deferred-images-before-frames": { description: "Load deferred frames before before saving fame contents", type: "boolean" },
|
|
115
115
|
"max-resource-size-enabled": { description: "Enable removal of embedded resources exceeding a given size", type: "boolean" },
|
|
116
|
-
"max-resource-size": { description: "Maximum size of embedded resources in MB
|
|
116
|
+
"max-resource-size": { description: "Maximum size of embedded resources in MB. It applies to every fetched resource, i.e. images, fonts, stylesheets, scripts, frames, videos and audios; a resource above the limit is left out of the saved page", type: "number", defaultValue: 10 }
|
|
117
117
|
}, {
|
|
118
118
|
"compress-css": { key: "compressCSS", description: "Compress CSS stylesheets", type: "boolean" },
|
|
119
119
|
"compress-html": { key: "compressHTML", description: "Compress HTML content", type: "boolean", defaultValue: true },
|
|
@@ -141,6 +141,7 @@ const OPTIONS_INFO = [{
|
|
|
141
141
|
"extract-data-from-page": { description: "Extract compressed data from the page instead of fetching the page in order to create universal self-extracting HTML files", type: "boolean", defaultValue: true },
|
|
142
142
|
"prevent-appended-data": { description: "Prevent appending data after the compressed data when creating self-extracting HTML files", type: "boolean" },
|
|
143
143
|
"declare-appended-data": { description: "Declare the data appended after the compressed data as the comment of the ZIP archive, for readers rejecting undeclared trailing bytes (e.g. java.util.zip); ZIP tools then print that data when listing the archive. Has no effect when the extra data is relocated ahead of the compressed data, because nothing is appended then", type: "boolean" },
|
|
144
|
+
"max-appended-data-length": { description: "Maximum number of bytes appended after the compressed data when creating self-extracting HTML files, 16361 by default. Readers only tolerate trailing bytes as far back as their end-of-archive scan reaches (16383 bytes for libarchive, 32768 for perl Archive::Zip, 65557 for Python zipfile); the default fits the narrowest. Data over the limit is relocated ahead of the compressed data instead", type: "number" },
|
|
144
145
|
"embed-screenshot": { description: "Embed a screenshot of the page as a PNG file in the compressed file (self-extracting HTML or ZIP file). When enabled, the resulting file can be read as a ZIP file or a PNG image.", type: "boolean" },
|
|
145
146
|
"embed-screenshot-options": { description: "Options passed to the CDP method `Page.captureScreenshot()` given as a JSON string (e.g. { \"captureBeyondViewport\": false })", type: "string" },
|
|
146
147
|
"embedded-image": { description: "Path to a PNG image to embed in the compressed file. Unlike --embed-screenshot it is also compatible with --crawl-save-archive, where it becomes the image of the whole archive.", type: "string" },
|
package/package.json
CHANGED
package/single-file-cli-api.js
CHANGED
|
@@ -27,11 +27,13 @@ import { Buffer } from "node:buffer";
|
|
|
27
27
|
import * as cdpBackend from "./lib/cdp-client.js";
|
|
28
28
|
import * as bidiBackend from "./lib/bidi-client.js";
|
|
29
29
|
import { getZipScriptSource } from "./lib/single-file-script.js";
|
|
30
|
-
import { createPagesArchive } from "./lib/single-file-archive.js";
|
|
30
|
+
import { createPagesArchive, PROCESS_OPTION_NAMES } from "./lib/single-file-archive.js";
|
|
31
31
|
import { Deno, path } from "./lib/deno-polyfill.js";
|
|
32
32
|
|
|
33
33
|
const VALID_URL_TEST = /^(https?|file):\/\//;
|
|
34
34
|
|
|
35
|
+
const ARCHIVE_EXCLUDED_OPTION_NAMES = ["createRootDirectory", "disableCompression", "insertTextBody", "password", "url"];
|
|
36
|
+
|
|
35
37
|
const DEFAULT_OPTIONS = {
|
|
36
38
|
removeHiddenElements: true,
|
|
37
39
|
removeUnusedStyles: true,
|
|
@@ -64,7 +66,7 @@ const STATE_PROCESSED = "processed";
|
|
|
64
66
|
const { readTextFile, writeTextFile, readFile, writeFile, stdout, mkdir, makeTempDir, remove, stat, errors } = Deno;
|
|
65
67
|
let backend = cdpBackend, tasks = [], maxParallelWorkers, sessionFilename, archiveTempDirectory, errorCount = 0;
|
|
66
68
|
|
|
67
|
-
export { initialize, closeBrowser };
|
|
69
|
+
export { initialize, closeBrowser, getArchiveOptions, ARCHIVE_EXCLUDED_OPTION_NAMES };
|
|
68
70
|
|
|
69
71
|
async function closeBrowser() {
|
|
70
72
|
await backend.closeBrowser();
|
|
@@ -196,6 +198,23 @@ async function finish(options) {
|
|
|
196
198
|
return errorCount;
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
function getArchiveOptions(options) {
|
|
202
|
+
const archiveOptions = {
|
|
203
|
+
zipScript: getZipScriptSource(),
|
|
204
|
+
dedupPages: options.crawlSaveArchiveDedup,
|
|
205
|
+
markUnarchivedLinks: options.crawlSaveArchiveMarkUnarchivedLinks,
|
|
206
|
+
tocPage: options.crawlSaveArchiveToc,
|
|
207
|
+
pageList: options.crawlSaveArchivePageList,
|
|
208
|
+
pageTransitions: options.crawlSaveArchivePageTransitions,
|
|
209
|
+
insertSingleFileComment: options.insertSingleFileComment,
|
|
210
|
+
removeSavedDate: options.removeSavedDate
|
|
211
|
+
};
|
|
212
|
+
PROCESS_OPTION_NAMES
|
|
213
|
+
.filter(name => !ARCHIVE_EXCLUDED_OPTION_NAMES.includes(name) && !(name in archiveOptions))
|
|
214
|
+
.forEach(name => archiveOptions[name] = options[name]);
|
|
215
|
+
return archiveOptions;
|
|
216
|
+
}
|
|
217
|
+
|
|
199
218
|
async function savePagesArchive(options) {
|
|
200
219
|
const archiveTasks = tasks.filter(task => task.archiveFilename);
|
|
201
220
|
if (archiveTasks.length) {
|
|
@@ -205,26 +224,7 @@ async function savePagesArchive(options) {
|
|
|
205
224
|
title: task.title,
|
|
206
225
|
getData: () => readFile(task.archiveFilename)
|
|
207
226
|
}));
|
|
208
|
-
const content = await createPagesArchive(pages,
|
|
209
|
-
zipScript: getZipScriptSource(),
|
|
210
|
-
dedupPages: options.crawlSaveArchiveDedup,
|
|
211
|
-
markUnarchivedLinks: options.crawlSaveArchiveMarkUnarchivedLinks,
|
|
212
|
-
tocPage: options.crawlSaveArchiveToc,
|
|
213
|
-
pageList: options.crawlSaveArchivePageList,
|
|
214
|
-
pageTransitions: options.crawlSaveArchivePageTransitions,
|
|
215
|
-
selfExtractingArchive: options.selfExtractingArchive,
|
|
216
|
-
extractDataFromPage: options.extractDataFromPage,
|
|
217
|
-
preventAppendedData: options.preventAppendedData,
|
|
218
|
-
declareAppendedData: options.declareAppendedData,
|
|
219
|
-
embeddedPdf: options.embeddedPdf,
|
|
220
|
-
embeddedImage: options.embeddedImage,
|
|
221
|
-
includeBOM: options.includeBOM,
|
|
222
|
-
insertMetaCSP: options.insertMetaCSP,
|
|
223
|
-
insertCanonicalLink: options.insertCanonicalLink,
|
|
224
|
-
insertMetaNoIndex: options.insertMetaNoIndex,
|
|
225
|
-
insertSingleFileComment: options.insertSingleFileComment,
|
|
226
|
-
removeSavedDate: options.removeSavedDate
|
|
227
|
-
});
|
|
227
|
+
const content = await createPagesArchive(pages, getArchiveOptions(options));
|
|
228
228
|
if (options.dumpContent && !options.output) {
|
|
229
229
|
await stdout.write(content);
|
|
230
230
|
} else {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The injected script is one concatenation: the SingleFile bundle, then the scripts named by
|
|
2
|
+
// --browser-script, then the call that hands SingleFile its options and its fetch fallback. A
|
|
3
|
+
// throw anywhere in it stops the rest, and nothing reported it — the browser does not surface an
|
|
4
|
+
// exception raised by a preload script, and the only guard was "is `singlefile` defined", which is
|
|
5
|
+
// already true by then. So a script that threw produced a save that looked ordinary and was
|
|
6
|
+
// missing the fetch fallback the CLI installs for the resources the page itself cannot get.
|
|
7
|
+
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
import { cliDirectory } from "../target.js";
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
const TEST_TIMEOUT = 120000;
|
|
22
|
+
|
|
23
|
+
test("a browser script runs in the page it saves", { timeout: TEST_TIMEOUT }, async () => {
|
|
24
|
+
const MARKER = "browser-script-marker";
|
|
25
|
+
await withPage(async ({ url, directory }) => {
|
|
26
|
+
const scriptPath = join(directory, "marker.js");
|
|
27
|
+
await writeFile(scriptPath, "addEventListener(\"DOMContentLoaded\",()=>{" +
|
|
28
|
+
"const marker=document.createElement(\"p\");marker.id=\"" + MARKER + "\";document.body.appendChild(marker);});");
|
|
29
|
+
const outputPath = join(directory, "out.html");
|
|
30
|
+
await execFileAsync(process.execPath, [
|
|
31
|
+
"single-file-node.js", url, outputPath,
|
|
32
|
+
"--browser-script", scriptPath
|
|
33
|
+
], { cwd: cliDirectory });
|
|
34
|
+
assert.match(await readFile(outputPath, "utf8"), new RegExp(MARKER));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a browser script that throws fails the save instead of degrading it", { timeout: TEST_TIMEOUT }, async () => {
|
|
39
|
+
const SCRIPT_ERROR_MESSAGE = "browser script boom";
|
|
40
|
+
await withPage(async ({ url, directory }) => {
|
|
41
|
+
const scriptPath = join(directory, "throwing.js");
|
|
42
|
+
await writeFile(scriptPath, "throw new Error(\"" + SCRIPT_ERROR_MESSAGE + "\");");
|
|
43
|
+
const outputPath = join(directory, "out.html");
|
|
44
|
+
let exitCode = 0, stderr = "";
|
|
45
|
+
try {
|
|
46
|
+
await execFileAsync(process.execPath, [
|
|
47
|
+
"single-file-node.js", url, outputPath,
|
|
48
|
+
"--browser-script", scriptPath
|
|
49
|
+
], { cwd: cliDirectory });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
({ code: exitCode, stderr } = error);
|
|
52
|
+
}
|
|
53
|
+
assert.notEqual(exitCode, 0, "a save whose injected script threw was reported as a success");
|
|
54
|
+
// the message the script threw is what says which script to look at: without it the
|
|
55
|
+
// failure names the execution context and leaves the cause to be guessed
|
|
56
|
+
assert.match(stderr, new RegExp(SCRIPT_ERROR_MESSAGE), "the failure does not name the error the script threw");
|
|
57
|
+
assert.ok(!existsSync(outputPath), "a page was written for a save that failed");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// The same concatenation swallowed code across file boundaries. The scripts were joined with "",
|
|
62
|
+
// and the initSingleFile call appended straight onto the last one, so a file whose final line was a
|
|
63
|
+
// // comment commented out whatever came next. It only bites when that file has no trailing newline,
|
|
64
|
+
// which is why it went unnoticed: an editor that adds one hides it completely. The last-file case
|
|
65
|
+
// failed loudly (initSingleFile never ran, "no valid SingleFile execution context"), but a file
|
|
66
|
+
// eating the NEXT script was silent — exit 0, a valid save, and one script quietly skipped.
|
|
67
|
+
test("a browser script ending in a comment does not swallow the next one", { timeout: TEST_TIMEOUT }, async () => {
|
|
68
|
+
const MARKER = "second-script-marker";
|
|
69
|
+
await withPage(async ({ url, directory }) => {
|
|
70
|
+
const firstPath = join(directory, "first.js");
|
|
71
|
+
const secondPath = join(directory, "second.js");
|
|
72
|
+
// deliberately no trailing newline: that is the whole trigger
|
|
73
|
+
await writeFile(firstPath, "globalThis.__first = 1; // a trailing comment");
|
|
74
|
+
await writeFile(secondPath, "addEventListener(\"DOMContentLoaded\",()=>{" +
|
|
75
|
+
"const marker=document.createElement(\"p\");marker.id=\"" + MARKER + "\";document.body.appendChild(marker);});\n");
|
|
76
|
+
const outputPath = join(directory, "out.html");
|
|
77
|
+
await execFileAsync(process.execPath, [
|
|
78
|
+
"single-file-node.js", url, outputPath,
|
|
79
|
+
"--browser-script", firstPath,
|
|
80
|
+
"--browser-script", secondPath
|
|
81
|
+
], { cwd: cliDirectory });
|
|
82
|
+
assert.match(await readFile(outputPath, "utf8"), new RegExp(MARKER),
|
|
83
|
+
"the first script's trailing comment swallowed the second one");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a lone browser script ending in a comment still saves the page", { timeout: TEST_TIMEOUT }, async () => {
|
|
88
|
+
await withPage(async ({ url, directory }) => {
|
|
89
|
+
const scriptPath = join(directory, "only.js");
|
|
90
|
+
await writeFile(scriptPath, "globalThis.__only = 1; // a trailing comment");
|
|
91
|
+
const outputPath = join(directory, "out.html");
|
|
92
|
+
await execFileAsync(process.execPath, [
|
|
93
|
+
"single-file-node.js", url, outputPath,
|
|
94
|
+
"--browser-script", scriptPath
|
|
95
|
+
], { cwd: cliDirectory });
|
|
96
|
+
assert.match(await readFile(outputPath, "utf8"), /<h1>page<\/h1>/,
|
|
97
|
+
"the trailing comment reached the initSingleFile call");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function withPage(run) {
|
|
102
|
+
const server = createServer((request, response) =>
|
|
103
|
+
response.writeHead(200, { "content-type": "text/html" }).end("<html><body><h1>page</h1></body></html>"));
|
|
104
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
105
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
106
|
+
try {
|
|
107
|
+
await run({ url: "http://localhost:" + server.address().port + "/", directory });
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(directory, { recursive: true, force: true });
|
|
110
|
+
server.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -84,7 +84,11 @@ async function capture(pathname, generations) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function getCanvasImage(html) {
|
|
87
|
-
|
|
87
|
+
// either property name is correct: core pins every background longhand on the canvas, and once
|
|
88
|
+
// background-attachment joined them CSSOM had a complete set and serialised them as the
|
|
89
|
+
// `background` shorthand. Only cssText changed — core reads the value back through
|
|
90
|
+
// getPropertyValue("background-image"), which is why the round trip above still holds
|
|
91
|
+
const match = html.match(/background(?:-image)?:\s*url\(["']?data:image\/png;base64,([^"')]+)["']?\)/);
|
|
88
92
|
return match && match[1];
|
|
89
93
|
}
|
|
90
94
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* global URL */
|
|
2
|
+
|
|
3
|
+
// CSS orders @font-face sources two ways at once, and a saved page has to honour both.
|
|
4
|
+
// Within one rule the browser uses the FIRST source that loads. Across duplicate rules sharing the
|
|
5
|
+
// same descriptors the LAST rule wins outright: measured in Chrome, only the later rule's font is
|
|
6
|
+
// fetched and document.fonts reports the earlier face as "unloaded", so it is never a fallback.
|
|
7
|
+
// SingleFile merged every rule sharing a font key into one array built with unshift, which reversed
|
|
8
|
+
// both axes at once. The within-rule axis then read correctly and the across-rule axis backwards,
|
|
9
|
+
// so the save embedded the font from the rule the browser had overridden — the right font was
|
|
10
|
+
// absent from the file entirely. The two cases below pin the axes against each other, because a fix
|
|
11
|
+
// that satisfies only one of them is the bug in the other direction.
|
|
12
|
+
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import process from "node:process";
|
|
23
|
+
import { Buffer } from "node:buffer";
|
|
24
|
+
import { cliDirectory } from "../target.js";
|
|
25
|
+
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
const TEST_TIMEOUT = 120000;
|
|
28
|
+
const FONTS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..", "fidelity", "pages", "fonts");
|
|
29
|
+
// three distinct fixtures, told apart in the save by their byte length
|
|
30
|
+
const FONT_NAMES = ["band.ttf", "bar.ttf", "block.ttf"];
|
|
31
|
+
// the two Dup rules differ in size-adjust, which getFontKey does not cover, so they share a key
|
|
32
|
+
// while rendering differently. That is what makes "keep the last rule" the only correct reduction:
|
|
33
|
+
// keeping the first would pair the later rule's font with the earlier rule's metrics
|
|
34
|
+
const PAGE = "<html><head><style>" +
|
|
35
|
+
"@font-face{font-family:\"Dup\";src:url(/fonts/band.ttf) format(\"truetype\");size-adjust:50%}" +
|
|
36
|
+
"@font-face{font-family:\"Dup\";src:url(/fonts/bar.ttf) format(\"truetype\");size-adjust:150%}" +
|
|
37
|
+
"@font-face{font-family:\"Solo\";src:url(/fonts/block.ttf) format(\"truetype\"),url(/fonts/band.ttf) format(\"truetype\")}" +
|
|
38
|
+
"h1{font-family:\"Dup\",serif}p{font-family:\"Solo\",serif}" +
|
|
39
|
+
"</style></head><body><h1>Head</h1><p>Body</p></body></html>";
|
|
40
|
+
|
|
41
|
+
let capturePromise;
|
|
42
|
+
|
|
43
|
+
test("duplicate @font-face rules resolve to the later rule, as the browser does", { timeout: TEST_TIMEOUT }, async () => {
|
|
44
|
+
const { embedded, sizes } = await getCaptureResult();
|
|
45
|
+
const dup = embedded.filter(rule => rule.family === "Dup");
|
|
46
|
+
assert.ok(dup.length > 0, "no Dup rule survived the save");
|
|
47
|
+
dup.forEach(rule => assert.equal(rule.length, sizes["bar.ttf"],
|
|
48
|
+
`a Dup rule embedded ${describe(rule.length, sizes)} instead of bar.ttf, the later rule's font`));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a shadowed @font-face rule is dropped, with its own descriptors", { timeout: TEST_TIMEOUT }, async () => {
|
|
52
|
+
const { embedded } = await getCaptureResult();
|
|
53
|
+
const dup = embedded.filter(rule => rule.family === "Dup");
|
|
54
|
+
assert.equal(dup.length, 1, "the shadowed rule was kept, so the font is embedded twice");
|
|
55
|
+
assert.match(dup[0].text, /size-adjust:\s*150%/, "the surviving rule is not the later one");
|
|
56
|
+
assert.doesNotMatch(dup[0].text, /size-adjust:\s*50%/, "the earlier rule's metrics survived");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a single @font-face rule still resolves to its first source", { timeout: TEST_TIMEOUT }, async () => {
|
|
60
|
+
const { embedded, sizes } = await getCaptureResult();
|
|
61
|
+
const solo = embedded.filter(rule => rule.family === "Solo");
|
|
62
|
+
assert.equal(solo.length, 1, "expected one Solo rule");
|
|
63
|
+
assert.equal(solo[0].length, sizes["block.ttf"],
|
|
64
|
+
`the Solo rule embedded ${describe(solo[0].length, sizes)} instead of block.ttf, its first source`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function describe(length, sizes) {
|
|
68
|
+
const name = Object.keys(sizes).find(fontName => sizes[fontName] === length);
|
|
69
|
+
return name || length + " bytes";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getCaptureResult() {
|
|
73
|
+
if (!capturePromise) {
|
|
74
|
+
capturePromise = runCapture();
|
|
75
|
+
}
|
|
76
|
+
return capturePromise;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function runCapture() {
|
|
80
|
+
const fonts = new Map();
|
|
81
|
+
const sizes = {};
|
|
82
|
+
for (const name of FONT_NAMES) {
|
|
83
|
+
const data = await readFile(join(FONTS_DIRECTORY, name));
|
|
84
|
+
fonts.set(name, data);
|
|
85
|
+
sizes[name] = data.length;
|
|
86
|
+
}
|
|
87
|
+
const server = createServer((request, response) => {
|
|
88
|
+
const { pathname } = new URL(request.url, "http://localhost");
|
|
89
|
+
const name = pathname.startsWith("/fonts/") && pathname.slice("/fonts/".length);
|
|
90
|
+
if (name && fonts.has(name)) {
|
|
91
|
+
response.writeHead(200, { "content-type": "font/ttf" }).end(fonts.get(name));
|
|
92
|
+
} else if (pathname === "/") {
|
|
93
|
+
response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
|
|
94
|
+
} else {
|
|
95
|
+
response.writeHead(404).end();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
99
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
100
|
+
try {
|
|
101
|
+
const outputPath = join(directory, "out.html");
|
|
102
|
+
await execFileAsync(process.execPath, ["single-file-node.js", "http://localhost:" + server.address().port + "/", outputPath], { cwd: cliDirectory });
|
|
103
|
+
const content = (await readFile(outputPath)).toString("utf8");
|
|
104
|
+
const embedded = [...content.matchAll(/@font-face\s*{[^}]*}/g)].map(match => {
|
|
105
|
+
const family = match[0].match(/font-family:\s*"?([^;"}]+)"?/);
|
|
106
|
+
const data = match[0].match(/base64,([A-Za-z0-9+/=]+)/);
|
|
107
|
+
return {
|
|
108
|
+
family: family && family[1].trim(),
|
|
109
|
+
length: data ? Buffer.from(data[1], "base64").length : 0,
|
|
110
|
+
text: match[0].replace(/base64,[A-Za-z0-9+/=]+/, "base64,...")
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
return { embedded, sizes };
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(directory, { recursive: true });
|
|
116
|
+
server.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// so a plain save embedded the HTML as src:url(data:text/html;base64,...) inside the @font-face,
|
|
6
6
|
// while --compress-content dropped it because only that path tests the bytes with FontFace. An
|
|
7
7
|
// HTML body at a media URL is never a usable resource, so both modes now treat it like a 404.
|
|
8
|
+
// A @font-face left with no source at all is dropped whole, rather than kept as an "src:" with
|
|
9
|
+
// nothing after it, which is the invalid declaration a browser discards anyway.
|
|
8
10
|
//
|
|
9
11
|
// The controls are a real font and a real image on the same page: they must still be embedded.
|
|
10
12
|
|
|
@@ -39,7 +41,7 @@ const capturePromises = new Map();
|
|
|
39
41
|
test("an HTML page served at a font URL is dropped from a plain save", { timeout: TEST_TIMEOUT }, async () => {
|
|
40
42
|
const content = (await getCaptureResult(false)).toString("utf8");
|
|
41
43
|
assert.ok(!content.includes("data:text/html"), "an HTML body was embedded as a resource");
|
|
42
|
-
assert.
|
|
44
|
+
assert.doesNotMatch(content, /@font-face\s*{[^}]*font-family:\s*"?Fake"?/, "the unusable @font-face rule was kept");
|
|
43
45
|
assert.match(content, /font-family:\s*"?Real"?;\s*src:\s*url\("?data:font\/ttf;base64,/, "the real font was not embedded");
|
|
44
46
|
});
|
|
45
47
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/* global URL */
|
|
2
|
+
|
|
3
|
+
// --max-appended-data-length caps the bytes a self-extracting page leaves after the ZIP end of
|
|
4
|
+
// central directory record. A recovery payload over the cap is relocated ahead of the compressed
|
|
5
|
+
// data instead, which is what makes the file readable by a reader whose end-of-archive scan window
|
|
6
|
+
// is narrow (16383 bytes for libarchive, against 65557 for Python zipfile).
|
|
7
|
+
//
|
|
8
|
+
// The assertion that matters is that the switch REACHES core at all. --declare-appended-data once
|
|
9
|
+
// shipped inert because core filtered the options it forwarded through a hand-written whitelist,
|
|
10
|
+
// and the CLI has an explicit list of its own on the multi-page path, so an option can be declared,
|
|
11
|
+
// parsed, documented and still do nothing. A capture at each end of the range is the cheap proof.
|
|
12
|
+
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import process from "node:process";
|
|
22
|
+
const { configure, ZipReader, Uint8ArrayReader } = await importLibModule("single-file-archive.js");
|
|
23
|
+
import { cliDirectory, importLibModule } from "../target.js";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
const TEST_TIMEOUT = 120000;
|
|
27
|
+
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
28
|
+
const END_OF_CENTRAL_DIRECTORY_LENGTH = 22;
|
|
29
|
+
const COMMENT_LENGTH_OFFSET = 20;
|
|
30
|
+
|
|
31
|
+
const capturePromises = new Map();
|
|
32
|
+
|
|
33
|
+
test("a payload over --max-appended-data-length is not appended", { timeout: TEST_TIMEOUT }, async () => {
|
|
34
|
+
const { data, stderr } = await getCaptureResult(0);
|
|
35
|
+
assert.equal(trailingBytes(data), 0, "nothing may follow the record when the budget is zero, stderr: " + stderr);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a payload within --max-appended-data-length is appended", { timeout: TEST_TIMEOUT }, async () => {
|
|
39
|
+
const { data, stderr } = await getCaptureResult(1000000);
|
|
40
|
+
assert.ok(trailingBytes(data) > 0, "a generous budget must leave the payload appended, stderr: " + stderr);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the archive stays readable at either end of the range", { timeout: TEST_TIMEOUT }, async () => {
|
|
44
|
+
for (const maxAppendedDataLength of [0, 1000000]) {
|
|
45
|
+
const { data } = await getCaptureResult(maxAppendedDataLength);
|
|
46
|
+
configure({ useWebWorkers: false });
|
|
47
|
+
const zipReader = new ZipReader(new Uint8ArrayReader(data));
|
|
48
|
+
const entryNames = (await zipReader.getEntries()).map(entry => entry.filename);
|
|
49
|
+
assert.ok(entryNames.includes("index.html"), "maxAppendedDataLength: " + maxAppendedDataLength);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function trailingBytes(data) {
|
|
54
|
+
const { offset, commentLength } = findEndOfCentralDirectory(data);
|
|
55
|
+
return data.length - (offset + END_OF_CENTRAL_DIRECTORY_LENGTH + commentLength);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getCaptureResult(maxAppendedDataLength) {
|
|
59
|
+
if (!capturePromises.has(maxAppendedDataLength)) {
|
|
60
|
+
capturePromises.set(maxAppendedDataLength, runCapture(maxAppendedDataLength));
|
|
61
|
+
}
|
|
62
|
+
return capturePromises.get(maxAppendedDataLength);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runCapture(maxAppendedDataLength) {
|
|
66
|
+
const server = createServer((request, response) => {
|
|
67
|
+
const { pathname } = new URL(request.url, "http://localhost");
|
|
68
|
+
if (pathname === "/") {
|
|
69
|
+
response.writeHead(200, { "content-type": "text/html" })
|
|
70
|
+
.end("<html><head><title>Budget Page</title></head><body>content</body></html>");
|
|
71
|
+
} else {
|
|
72
|
+
response.writeHead(404).end();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
76
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
77
|
+
try {
|
|
78
|
+
const origin = "http://localhost:" + server.address().port;
|
|
79
|
+
const { stderr } = await execFileAsync(process.execPath, [
|
|
80
|
+
"single-file-node.js", origin + "/", join(directory, "page.html"),
|
|
81
|
+
"--compress-content",
|
|
82
|
+
"--max-appended-data-length=" + maxAppendedDataLength
|
|
83
|
+
], { cwd: cliDirectory });
|
|
84
|
+
const data = new Uint8Array(await readFile(join(directory, "page.html")));
|
|
85
|
+
return { data, stderr };
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(directory, { recursive: true });
|
|
88
|
+
server.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findEndOfCentralDirectory(data) {
|
|
93
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
94
|
+
for (let offset = data.length - END_OF_CENTRAL_DIRECTORY_LENGTH; offset >= 0; offset--) {
|
|
95
|
+
if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
|
96
|
+
return { offset, commentLength: view.getUint16(offset + COMMENT_LENGTH_OFFSET, true) };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new Error("end of central directory record not found");
|
|
100
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The multi-page archive path used to hand-list the 19 options it forwarded to createPagesArchive.
|
|
2
|
+
// The single-page path passes the whole options object through, so a compression option added to
|
|
3
|
+
// core reached every ordinary capture and was silently ignored by --crawl-save-archive alone —
|
|
4
|
+
// which is the worst shape for this kind of gap, because the option looks wired. Core removed the
|
|
5
|
+
// same trap on its side by exporting PROCESS_OPTION_NAMES instead of copying the names; the
|
|
6
|
+
// forwarding here now derives from that list, and what is deliberately withheld is named.
|
|
7
|
+
//
|
|
8
|
+
// --max-appended-data-length is what found this: it needed a line here as well as in options.js,
|
|
9
|
+
// and would otherwise have shipped working everywhere except archives.
|
|
10
|
+
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { importLibModule } from "../target.js";
|
|
14
|
+
import { getArchiveOptions, ARCHIVE_EXCLUDED_OPTION_NAMES } from "../../single-file-cli-api.js";
|
|
15
|
+
const { PROCESS_OPTION_NAMES } = await importLibModule("single-file-archive.js");
|
|
16
|
+
|
|
17
|
+
// the keys forwarded before the refactor, pinned so the derivation cannot quietly change what the
|
|
18
|
+
// archive path receives
|
|
19
|
+
const FORWARDED = [
|
|
20
|
+
"zipScript", "dedupPages", "markUnarchivedLinks", "tocPage", "pageList", "pageTransitions",
|
|
21
|
+
"insertSingleFileComment", "removeSavedDate", "declareAppendedData", "embeddedImage",
|
|
22
|
+
"embeddedPdf", "extractDataFromPage", "includeBOM", "insertCanonicalLink", "insertMetaCSP",
|
|
23
|
+
"insertMetaNoIndex", "maxAppendedDataLength", "preventAppendedData", "selfExtractingArchive"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
test("every compression option is either forwarded to the archive or deliberately withheld", () => {
|
|
27
|
+
const forwarded = Object.keys(getArchiveOptions({}));
|
|
28
|
+
const unaccounted = PROCESS_OPTION_NAMES
|
|
29
|
+
.filter(name => !forwarded.includes(name) && !ARCHIVE_EXCLUDED_OPTION_NAMES.includes(name));
|
|
30
|
+
assert.deepEqual(unaccounted, [],
|
|
31
|
+
"core gained a compression option the archive path neither forwards nor names as withheld, so it is silently ignored by --crawl-save-archive; forward it, or add it to ARCHIVE_EXCLUDED_OPTION_NAMES");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("the archive receives exactly the options it received before the derivation", () => {
|
|
35
|
+
assert.deepEqual(Object.keys(getArchiveOptions({})).sort(), FORWARDED.slice().sort());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the withheld options are real compression options, not typos", () => {
|
|
39
|
+
const unknown = ARCHIVE_EXCLUDED_OPTION_NAMES.filter(name => !PROCESS_OPTION_NAMES.includes(name));
|
|
40
|
+
assert.deepEqual(unknown, [], "a withheld name that core does not define hides a real gap behind a dead entry");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a crawl option is renamed on its way to the archive", () => {
|
|
44
|
+
const forwarded = getArchiveOptions({ crawlSaveArchiveDedup: true, crawlSaveArchiveToc: true });
|
|
45
|
+
assert.equal(forwarded.dedupPages, true);
|
|
46
|
+
assert.equal(forwarded.tocPage, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a compression option reaches the archive under its own name", () => {
|
|
50
|
+
assert.equal(getArchiveOptions({ maxAppendedDataLength: 4096 }).maxAppendedDataLength, 4096);
|
|
51
|
+
});
|