single-file-cli 2.4.1 → 2.6.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/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "2.4.1";
1
+ export const version = "2.6.0";
package/options.js CHANGED
@@ -65,7 +65,7 @@ const OPTIONS_INFO = [{
65
65
  "browser-single-process": { description: "Run the browser as a single process (enabled by default on Windows only, current browsers on other platforms do not support this mode)", type: "boolean", defaultValue: build.os == "windows" },
66
66
  "browser-start-minimized": { description: "Minimize the browser", type: "boolean" },
67
67
  "browser-ignore-insecure-certs": { description: "Ignore HTTPs errors", type: "boolean" },
68
- "browser-bypass-CSP": { description: "Bypass the Content Security Policy of the page, needed to save pages enforcing Trusted Types with browsers based on Chromium 150 or older", type: "boolean", defaultValue: false }
68
+ "browser-bypass-csp": { key: "browserBypassCSP", description: "Bypass the Content Security Policy of the page, needed to save pages enforcing Trusted Types with browsers based on Chromium 150 or older", type: "boolean", defaultValue: false }
69
69
  }, {
70
70
  "browser-load-max-time": { description: "Maximum delay of time to wait for loading the page in ms", type: "number", defaultValue: 60000 },
71
71
  "browser-capture-max-time": { description: "Maximum delay of time to wait for capturing the page in ms", type: "number", defaultValue: 60000 },
@@ -102,7 +102,7 @@ const OPTIONS_INFO = [{
102
102
  "block-stylesheets": { description: "Block stylesheets", type: "boolean", defaultValue: false },
103
103
  "block-videos": { description: "Block videos", type: "boolean", defaultValue: true },
104
104
  "block-mixed-content": { description: "Block mixed contents", type: "boolean" },
105
- "blocked-URL-pattern": { description: "Regular expression matching URLs to block (e.g. 'annoying-banners\\.com')", type: "string[]" }
105
+ "blocked-url-pattern": { key: "blockedURLPatterns", description: "Regular expression matching URLs to block (e.g. 'annoying-banners\\.com')", type: "string[]" }
106
106
  }, {
107
107
  "load-deferred-images": { description: "Load deferred (a.k.a. lazy-loaded) images", type: "boolean", defaultValue: true },
108
108
  "load-deferred-images-dispatch-scroll-event": { description: "Dispatch 'scroll' event when loading deferred images", type: "boolean" },
@@ -112,8 +112,8 @@ const OPTIONS_INFO = [{
112
112
  "max-resource-size-enabled": { description: "Enable removal of embedded resources exceeding a given size", type: "boolean" },
113
113
  "max-resource-size": { description: "Maximum size of embedded resources in MB (i.e. images, stylesheets, scripts and iframes)", type: "number", defaultValue: 10 }
114
114
  }, {
115
- "compress-CSS": { description: "Compress CSS stylesheets", type: "boolean" },
116
- "compress-HTML": { description: "Compress HTML content", type: "boolean", defaultValue: true },
115
+ "compress-css": { key: "compressCSS", description: "Compress CSS stylesheets", type: "boolean" },
116
+ "compress-html": { key: "compressHTML", description: "Compress HTML content", type: "boolean", defaultValue: true },
117
117
  "remove-frames": { description: "Remove frames", type: "boolean" },
118
118
  "remove-hidden-elements": { description: "Remove HTML elements which are not displayed", type: "boolean", defaultValue: true },
119
119
  "removed-elements-selector": { description: "Remove specific HTML elements matching the given CSS selectors (comma separated)", type: "string" },
@@ -129,18 +129,20 @@ const OPTIONS_INFO = [{
129
129
  "group-duplicate-stylesheets": { description: "Group duplicate inline stylesheets into a single stylesheet in order to reduce the size of the page", type: "boolean", defaultValue: false }
130
130
  }, {
131
131
  "compress-content": { description: "Create a ZIP file instead of an HTML file", type: "boolean" },
132
+ "disable-compression": { description: "Store the files of the ZIP file without compressing them", type: "boolean" },
132
133
  "self-extracting-archive": { description: "Create a self-extracting (ZIP) HTML file", type: "boolean", defaultValue: true },
133
134
  "password": { description: "Password of the zip file when using --compress-content or --self-extracting-archive", type: "string" },
134
135
  "insert-text-body": { description: "Insert the text of the page into the self-extracting HTML file", type: "boolean" },
135
136
  "create-root-directory": { description: "Create a root directory based on the timestamp", type: "boolean" },
136
137
  "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 },
137
138
  "prevent-appended-data": { description: "Prevent appending data after the compressed data when creating self-extracting HTML files", type: "boolean" },
139
+ "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", type: "boolean" },
138
140
  "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" },
139
141
  "embed-screenshot-options": { description: "Options passed to the CDP method `Page.captureScreenshot()` given as a JSON string (e.g. { \"captureBeyondViewport\": false })", type: "string" },
140
- "embedded-image": { description: "Path to a PNG image to embed in the compressed file.", type: "string" },
142
+ "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" },
141
143
  "embed-pdf": { description: "Embed a PDF file in the ZIP or self-extracting file. When enabled, the resulting file can be read as a ZIP file or a PDF file.", type: "boolean" },
142
144
  "embed-pdf-options": { description: "Options passed to the CDP method `Page.printToPDF()` given as a JSON string (e.g. { \"pageRanges\": \"1-1\", \"paperWidth\": 11, \"paperHeight\": 8.5 })", type: "string" },
143
- "embedded-pdf": { description: "Path to a PDF file to embed in the compressed file.", type: "string" }
145
+ "embedded-pdf": { description: "Path to a PDF file to embed in the compressed file. Unlike --embed-pdf it is also compatible with --crawl-save-archive, where it becomes the PDF of the whole archive.", type: "string" }
144
146
  }, {
145
147
  "filename-template": { description: "Template used to generate the output filename (see https://github.com/gildas-lormeau/SingleFile/wiki/Template-variables-and-functions)", type: "string", defaultValue: "%if-empty<{page-title}|No title> ({date-locale} {time-locale}).{filename-extension}" },
146
148
  "filename-conflict-action": { description: "Action when the filename is conflicting with existing one on the filesystem. The possible values are \"uniquify\" (default), \"overwrite\" and \"skip\"", type: "string", defaultValue: "uniquify" },
@@ -155,17 +157,18 @@ const OPTIONS_INFO = [{
155
157
  "crawl-inner-links-only": { description: "Crawl pages found via inner links only if they are hosted on the same domain", type: "boolean", defaultValue: true },
156
158
  "crawl-no-parent": { description: "Crawl pages found via inner links only if their URLs are not parent of the URL to crawl", type: "boolean" },
157
159
  "crawl-load-session": { description: "Name of the file of the session to load (previously saved with --crawl-save-session or --crawl-sync-session)", type: "string" },
158
- "crawl-remove-URL-fragment": { description: "Remove URL fragments found in links", type: "boolean", defaultValue: true },
160
+ "crawl-remove-url-fragment": { key: "crawlRemoveURLFragment", description: "Remove URL fragments found in links", type: "boolean", defaultValue: true },
159
161
  "crawl-save-session": { description: "Name of the file where to save the state of the session", type: "string" },
160
162
  "crawl-sync-session": { description: "Name of the file where to load and save the state of the session", type: "string" },
161
163
  "crawl-max-depth": { description: "Max depth when crawling pages found in internal and external links (0: infinite)", type: "number", defaultValue: 1 },
162
164
  "crawl-external-links-max-depth": { description: "Max depth when crawling pages found in external links (0: infinite)", type: "number", defaultValue: 1 },
163
- "crawl-replace-URLs": { description: "Replace URLs of saved pages with relative paths of saved pages on the filesystem", type: "boolean" },
165
+ "crawl-replace-urls": { key: "crawlReplaceURLs", description: "Replace URLs of saved pages with relative paths of saved pages on the filesystem", type: "boolean" },
164
166
  "crawl-rewrite-rule": { description: "Rewrite rule used to rewrite URLs of crawled pages", type: "string[]" },
165
167
  "crawl-save-archive": { description: "Save all the crawled pages into a single (self-extracting) ZIP file, requires --compress-content", type: "boolean" },
166
168
  "crawl-save-archive-dedup": { description: "Deduplicate identical resources shared between pages when using --crawl-save-archive", type: "boolean" },
167
169
  "crawl-save-archive-mark-unarchived-links": { description: "Mark links to pages not saved in the archive when using --crawl-save-archive", type: "boolean" },
168
170
  "crawl-save-archive-page-transitions": { description: "Page transitions when navigating in the archive saved with --crawl-save-archive. The possible values are \"auto\" (default, i.e. transitions run when pages opt in via CSS), \"fade\" and \"none\"", type: "string", defaultValue: "auto" },
171
+ "crawl-save-archive-page-list": { description: "Insert the list of the crawled pages in the prelude of the archive when using --crawl-save-archive, so that indexing tools can read it without extracting the archive", type: "boolean" },
169
172
  "crawl-save-archive-toc": { description: "Save a table of contents page into the archive when using --crawl-save-archive", type: "boolean" },
170
173
  }, {
171
174
  "browser-script": { description: "Path of a script executed in the page (and all the frames) before it is loaded", type: "string[]" },
@@ -183,10 +186,10 @@ const OPTIONS_INFO = [{
183
186
  "infobar-position-right": { description: "Position the infobar at the right of the page", type: "string", defaultValue: "16px" },
184
187
  "infobar-position-left": { description: "Position the infobar at the left of the page", type: "string", defaultValue: "" },
185
188
  }, {
186
- "include-BOM": { description: "Include the UTF-8 BOM into the HTML page", type: "boolean" },
187
- "insert-meta-CSP": { description: "Include a <meta> tag with a CSP to avoid potential requests to internet when viewing a page", type: "boolean", defaultValue: true },
189
+ "include-bom": { key: "includeBOM", description: "Include the UTF-8 BOM into the HTML page, ignored when the page is compressed unless --extract-data-from-page is disabled and no image is embedded", type: "boolean" },
190
+ "insert-meta-csp": { key: "insertMetaCSP", description: "Include a <meta> tag with a CSP to avoid potential requests to internet when viewing a page", type: "boolean", defaultValue: true },
188
191
  "remove-saved-date": { description: "Remove saved date metadata in HTML header", type: "boolean" },
189
- "save-original-URLs": { description: "Save the original URLS in the embedded contents", type: "boolean" },
192
+ "save-original-urls": { key: "saveOriginalURLs", description: "Save the original URLS in the embedded contents", type: "boolean" },
190
193
  "insert-single-file-comment": { description: "Insert a comment in the HTML header with the URL of the page", type: "boolean", defaultValue: true },
191
194
  "resolve-links": { description: "Resolve link URLs to absolute URLs", type: "boolean", defaultValue: true }
192
195
  }, {
@@ -214,7 +217,7 @@ const OPTIONS_INFO = [{
214
217
  const CRAWL_LINKS_DEPENDENT_OPTIONS = {
215
218
  crawlInnerLinksOnly: "--crawl-inner-links-only",
216
219
  crawlNoParent: "--crawl-no-parent",
217
- crawlRemoveURLFragment: "--crawl-remove-URL-fragment",
220
+ crawlRemoveURLFragment: "--crawl-remove-url-fragment",
218
221
  crawlMaxDepth: "--crawl-max-depth",
219
222
  crawlExternalLinksMaxDepth: "--crawl-external-links-max-depth",
220
223
  crawlRewriteRules: "--crawl-rewrite-rule"
@@ -327,6 +330,14 @@ function getOptions() {
327
330
  .filter(optionKey => explicitOptions[optionKey] !== undefined)
328
331
  .forEach(optionKey => errorMessages.push(`${CRAWL_LINKS_DEPENDENT_OPTIONS[optionKey]} requires --crawl-links`));
329
332
  }
333
+ // the byte order mark is written by the CLI when the page is saved as HTML, and by the
334
+ // compression processor in the prelude of a self-extracting file — but a universal file
335
+ // declares a single-byte charset the mark would break, and an embedded image must start
336
+ // with the PNG signature, so there is nowhere left to write it
337
+ if (options.includeBOM && options.compressContent &&
338
+ (!options.selfExtractingArchive || options.extractDataFromPage || options.embeddedImage || options.embedScreenshot)) {
339
+ console.error("Warning: --include-bom is ignored, the file cannot start with a byte order mark. It requires --self-extracting-archive, --extract-data-from-page=false and no embedded image"); // eslint-disable-line no-console
340
+ }
330
341
  if (errorMessages.length) {
331
342
  printUsage();
332
343
  errorMessages.forEach(message => console.error(`Error: ${message}`)); // eslint-disable-line no-console
@@ -543,6 +554,11 @@ function parseArgs(args, setDefaultValues = true) {
543
554
 
544
555
  function getOptionKey(optionKeyName, optionInfo) {
545
556
  if (optionInfo) {
557
+ // option names are lowercase, the keys they fill are not always: the acronyms of
558
+ // "insertMetaCSP" and its kind cannot be derived from "insert-meta-csp", so they are declared
559
+ if (optionInfo.key) {
560
+ return optionInfo.key;
561
+ }
546
562
  const optionName = optionInfo.alias || optionKeyName;
547
563
  if (isArray(optionInfo.type)) {
548
564
  return kebabToCamelCase(optionName + "s");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "SingleFile CLI",
5
5
  "author": "Gildas Lormeau",
6
6
  "license": "AGPL-3.0-or-later",
@@ -79,6 +79,9 @@ async function initialize(options) {
79
79
  if (options.crawlSaveArchiveToc && !options.crawlSaveArchive) {
80
80
  throw new Error("--crawl-save-archive-toc requires --crawl-save-archive");
81
81
  }
82
+ if (options.crawlSaveArchivePageList && !options.crawlSaveArchive) {
83
+ throw new Error("--crawl-save-archive-page-list requires --crawl-save-archive");
84
+ }
82
85
  if (options.crawlSaveArchivePageTransitions !== undefined && !["auto", "fade", "none"].includes(options.crawlSaveArchivePageTransitions)) {
83
86
  throw new Error("--crawl-save-archive-page-transitions must be \"auto\", \"fade\" or \"none\"");
84
87
  }
@@ -89,9 +92,11 @@ async function initialize(options) {
89
92
  if (!options.compressContent) {
90
93
  throw new Error("--crawl-save-archive requires --compress-content");
91
94
  }
92
- if (options.embedPdf || options.embeddedPdf || options.embedScreenshot || options.embeddedImage ||
93
- options.outputJson || options.insertTextBody || options.password) {
94
- throw new Error("--crawl-save-archive is not compatible with --embed-pdf, --embedded-pdf, --embed-screenshot, --embedded-image, --output-json, --insert-text-body and --password");
95
+ if (options.embedPdf || options.embedScreenshot) {
96
+ throw new Error("--crawl-save-archive is not compatible with --embed-pdf and --embed-screenshot: an archive has a single PDF and a single image, and they cannot be rendered from one page among many. Use --embedded-pdf and --embedded-image to provide them");
97
+ }
98
+ if (options.outputJson || options.insertTextBody || options.password) {
99
+ throw new Error("--crawl-save-archive is not compatible with --output-json, --insert-text-body and --password");
95
100
  }
96
101
  archiveTempDirectory = await makeTempDir();
97
102
  }
@@ -199,10 +204,14 @@ async function savePagesArchive(options) {
199
204
  dedupPages: options.crawlSaveArchiveDedup,
200
205
  markUnarchivedLinks: options.crawlSaveArchiveMarkUnarchivedLinks,
201
206
  tocPage: options.crawlSaveArchiveToc,
207
+ pageList: options.crawlSaveArchivePageList,
202
208
  pageTransitions: options.crawlSaveArchivePageTransitions,
203
209
  selfExtractingArchive: options.selfExtractingArchive,
204
210
  extractDataFromPage: options.extractDataFromPage,
205
211
  preventAppendedData: options.preventAppendedData,
212
+ declareAppendedData: options.declareAppendedData,
213
+ embeddedPdf: options.embeddedPdf,
214
+ embeddedImage: options.embeddedImage,
206
215
  includeBOM: options.includeBOM,
207
216
  insertMetaCSP: options.insertMetaCSP,
208
217
  insertCanonicalLink: options.insertCanonicalLink,
@@ -243,6 +252,10 @@ async function runNextTask() {
243
252
  taskOptions.selfExtractingArchive = false;
244
253
  taskOptions.extractDataFromPage = false;
245
254
  taskOptions.createRootDirectory = false;
255
+ // the faces belong to the archive, not to the pages it holds: leaving them here would
256
+ // embed the same PDF and the same image in every page, and copy them once per task
257
+ taskOptions.embeddedPdf = undefined;
258
+ taskOptions.embeddedImage = undefined;
246
259
  taskOptions.archiveFilename = archiveTempDirectory + "/" + tasks.indexOf(task) + ".zip";
247
260
  }
248
261
  task.status = STATE_PROCESSING;
@@ -364,6 +377,13 @@ async function capturePage(options) {
364
377
  options.zipScript = getZipScriptSource();
365
378
  const pageData = await backend.getPageData(options);
366
379
  content = pageData.content;
380
+ // the compressed path emits the BOM inside the archive prologue, where only the writer
381
+ // can place it; for plain HTML it belongs to whoever saves the file, which is the
382
+ // extension's download layer there and this function here. The string test keeps the
383
+ // two apart: compressed content arrives as bytes
384
+ if (options.includeBOM && typeof content == "string") {
385
+ content = "\ufeff" + content;
386
+ }
367
387
  if (options.consoleMessagesFile && pageData.consoleMessages) {
368
388
  await writeTextFile(options.consoleMessagesFile, JSON.stringify(pageData.consoleMessages, null, 2));
369
389
  }
@@ -28,6 +28,8 @@ import { getOptions, applySettings, parseUrlsFile } from "./options.js";
28
28
 
29
29
  const { readTextFile, readFile, exit, addSignalListener, build } = Deno;
30
30
  const QUIT_BROWSER_HINT = build.os == "darwin" ? " (Cmd+Q)" : "";
31
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
32
+ const PDF_SIGNATURE = [0x25, 0x50, 0x44, 0x46, 0x2d];
31
33
 
32
34
  try {
33
35
  addSignalListener("SIGTERM", closeBrowserAndExit);
@@ -69,9 +71,11 @@ async function run() {
69
71
  }
70
72
  if (options.embeddedImage) {
71
73
  options.embeddedImage = Array.from(await readFile(options.embeddedImage));
74
+ checkSignature(options.embeddedImage, PNG_SIGNATURE, "--embedded-image", "PNG");
72
75
  }
73
76
  if (options.embeddedPdf) {
74
77
  options.embeddedPdf = Array.from(await readFile(options.embeddedPdf));
78
+ checkSignature(options.embeddedPdf, PDF_SIGNATURE, "--embedded-pdf", "PDF");
75
79
  }
76
80
  options.retrieveLinks = true;
77
81
  const singlefile = await initialize(options);
@@ -125,4 +129,12 @@ async function closeBrowserAndExit(code) {
125
129
 
126
130
  async function getUrlsFile(urlsFile) {
127
131
  return parseUrlsFile(await readTextFile(urlsFile));
128
- }
132
+ }
133
+
134
+ // the faces are read from the bytes the file starts with: a PDF whose header sits further in
135
+ // is dropped by PDF readers, and a file that is not a PNG produces an image nothing can open
136
+ function checkSignature(data, signature, optionName, formatName) {
137
+ if (signature.some((byte, index) => data[index] != byte)) {
138
+ throw new Error(optionName + " must be given a " + formatName + " file, and it must start with the " + formatName + " signature");
139
+ }
140
+ }
@@ -0,0 +1,54 @@
1
+ /* global URL */
2
+
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { createServer } from "node:http";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import process from "node:process";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+
17
+ test("a blocked URL is never requested", { timeout: 120000 }, async () => {
18
+ const BLOCKED_STYLE = "rgb(123,45,67)";
19
+ const KEPT_STYLE = "rgb(89,89,89)";
20
+ const requestedPaths = [];
21
+ const server = createServer((request, response) => {
22
+ const { pathname } = new URL(request.url, "http://localhost");
23
+ requestedPaths.push(pathname);
24
+ if (pathname === "/tracker.css") {
25
+ response.writeHead(200, { "content-type": "text/css" }).end("h2 { color: " + BLOCKED_STYLE + "; }");
26
+ } else if (pathname === "/kept.css") {
27
+ response.writeHead(200, { "content-type": "text/css" }).end("h3 { color: " + KEPT_STYLE + "; }");
28
+ } else {
29
+ response.writeHead(200, { "content-type": "text/html" }).end(
30
+ "<html><head><link rel=\"stylesheet\" href=\"/tracker.css\"><link rel=\"stylesheet\" href=\"/kept.css\"></head>" +
31
+ "<body><h2>blocked</h2><h3>kept</h3></body></html>");
32
+ }
33
+ });
34
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
35
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
36
+ try {
37
+ const outputPath = join(directory, "out.html");
38
+ const url = "http://localhost:" + server.address().port + "/top.html";
39
+ await execFileAsync(process.execPath, [
40
+ "single-file-node.js", url, outputPath,
41
+ "--blocked-URL-pattern", "tracker"
42
+ ], { cwd: cliDirectory });
43
+ const content = await readFile(outputPath, "utf8");
44
+ // dropping the response is not blocking: the server must never be asked
45
+ // for the resource in the first place
46
+ assert.ok(!requestedPaths.includes("/tracker.css"), "the blocked URL was requested from the server");
47
+ assert.ok(!content.includes(BLOCKED_STYLE), "a blocked stylesheet leaked into the page");
48
+ assert.ok(requestedPaths.includes("/kept.css"), "an unblocked resource was not requested");
49
+ assert.ok(content.includes(KEPT_STYLE), "an unblocked stylesheet was not inlined");
50
+ } finally {
51
+ await rm(directory, { recursive: true });
52
+ server.close();
53
+ }
54
+ });
@@ -0,0 +1,93 @@
1
+ /* global URL, TextDecoder */
2
+
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { createServer } from "node:http";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import process from "node:process";
13
+ import { configure, ZipReader, Uint8ArrayReader } from "../../lib/single-file-archive.js";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+ const TEST_TIMEOUT = 120000;
18
+ const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
19
+ const END_OF_CENTRAL_DIRECTORY_LENGTH = 22;
20
+ const COMMENT_LENGTH_OFFSET = 20;
21
+
22
+ const capturePromises = new Map();
23
+
24
+ test("data is appended after the end of central directory record by default", { timeout: TEST_TIMEOUT }, async () => {
25
+ const { data, stderr } = await getCaptureResult();
26
+ const { offset, commentLength } = findEndOfCentralDirectory(data);
27
+ assert.equal(commentLength, 0, "stderr: " + stderr);
28
+ assert.ok(data.length > offset + END_OF_CENTRAL_DIRECTORY_LENGTH, "the capture appends no data to declare");
29
+ });
30
+
31
+ test("--declare-appended-data declares the appended data as the archive comment", { timeout: TEST_TIMEOUT }, async () => {
32
+ const { data, stderr } = await getCaptureResult(true);
33
+ const { offset, commentLength } = findEndOfCentralDirectory(data);
34
+ assert.ok(commentLength > 0, "stderr: " + stderr);
35
+ assert.equal(offset + END_OF_CENTRAL_DIRECTORY_LENGTH + commentLength, data.length,
36
+ "the comment must cover every byte after the end of central directory record");
37
+ const comment = new TextDecoder("windows-1252").decode(data.subarray(data.length - commentLength));
38
+ assert.ok(comment.startsWith("-->"), "the comment must hold the appended data itself, got " + JSON.stringify(comment.slice(0, 32)));
39
+ });
40
+
41
+ test("the archive stays readable whether or not the appended data is declared", { timeout: TEST_TIMEOUT }, async () => {
42
+ for (const declareAppendedData of [false, true]) {
43
+ const { data } = await getCaptureResult(declareAppendedData);
44
+ configure({ useWebWorkers: false });
45
+ const zipReader = new ZipReader(new Uint8ArrayReader(data));
46
+ const entryNames = (await zipReader.getEntries()).map(entry => entry.filename);
47
+ assert.ok(entryNames.includes("index.html"), "declareAppendedData: " + declareAppendedData);
48
+ }
49
+ });
50
+
51
+ function getCaptureResult(declareAppendedData = false) {
52
+ if (!capturePromises.has(declareAppendedData)) {
53
+ capturePromises.set(declareAppendedData, runCapture(declareAppendedData));
54
+ }
55
+ return capturePromises.get(declareAppendedData);
56
+ }
57
+
58
+ async function runCapture(declareAppendedData) {
59
+ const server = createServer((request, response) => {
60
+ const { pathname } = new URL(request.url, "http://localhost");
61
+ if (pathname === "/") {
62
+ response.writeHead(200, { "content-type": "text/html" })
63
+ .end("<html><head><title>Compressed Page</title></head><body>content</body></html>");
64
+ } else {
65
+ response.writeHead(404).end();
66
+ }
67
+ });
68
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
69
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
70
+ try {
71
+ const origin = "http://localhost:" + server.address().port;
72
+ const { stderr } = await execFileAsync(process.execPath, [
73
+ "single-file-node.js", origin + "/", join(directory, "page.html"),
74
+ "--compress-content",
75
+ ...(declareAppendedData ? ["--declare-appended-data"] : [])
76
+ ], { cwd: cliDirectory });
77
+ const data = new Uint8Array(await readFile(join(directory, "page.html")));
78
+ return { data, stderr };
79
+ } finally {
80
+ await rm(directory, { recursive: true });
81
+ server.close();
82
+ }
83
+ }
84
+
85
+ function findEndOfCentralDirectory(data) {
86
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
87
+ for (let offset = data.length - END_OF_CENTRAL_DIRECTORY_LENGTH; offset >= 0; offset--) {
88
+ if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
89
+ return { offset, commentLength: view.getUint16(offset + COMMENT_LENGTH_OFFSET, true) };
90
+ }
91
+ }
92
+ throw new Error("end of central directory record not found");
93
+ }
@@ -96,6 +96,12 @@ test("--crawl-save-archive-toc requires --crawl-save-archive", { timeout: TEST_T
96
96
  error => error.stderr.includes("--crawl-save-archive-toc requires --crawl-save-archive"));
97
97
  });
98
98
 
99
+ test("--crawl-save-archive-page-list requires --crawl-save-archive", { timeout: TEST_TIMEOUT }, async () => {
100
+ await assert.rejects(
101
+ execFileAsync(process.execPath, ["single-file-node.js", "http://localhost/", "--compress-content", "--crawl-save-archive-page-list"], { cwd: cliDirectory }),
102
+ error => error.stderr.includes("--crawl-save-archive-page-list requires --crawl-save-archive"));
103
+ });
104
+
99
105
  test("--crawl-save-archive-mark-unarchived-links requires --crawl-save-archive", { timeout: TEST_TIMEOUT }, async () => {
100
106
  await assert.rejects(
101
107
  execFileAsync(process.execPath, ["single-file-node.js", "http://localhost/", "--compress-content", "--crawl-save-archive-mark-unarchived-links"], { cwd: cliDirectory }),
@@ -0,0 +1,61 @@
1
+ /* global URL */
2
+
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { createServer } from "node:http";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import process from "node:process";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+
17
+ test("extra HTTP headers do not make cross-origin resources send a preflight", { timeout: 120000 }, async () => {
18
+ const STYLE = "rgb(11,22,33)";
19
+ const requests = [];
20
+ // a server sharing its resources with any origin but not answering preflights,
21
+ // the shape that loses the resource when the request is preflighted
22
+ const resourceServer = createServer((request, response) => {
23
+ const { pathname } = new URL(request.url, "http://127.0.0.1");
24
+ requests.push({ method: request.method, headers: request.headers });
25
+ if (request.method === "OPTIONS") {
26
+ response.writeHead(403).end();
27
+ } else if (pathname === "/cors.css") {
28
+ response.writeHead(200, { "content-type": "text/css", "access-control-allow-origin": "*" }).end("h2 { color: " + STYLE + "; }");
29
+ } else {
30
+ response.writeHead(404).end();
31
+ }
32
+ });
33
+ await new Promise(resolve => resourceServer.listen(0, "127.0.0.1", resolve));
34
+ const styleUrl = "http://127.0.0.1:" + resourceServer.address().port + "/cors.css";
35
+ const server = createServer((request, response) => {
36
+ response.writeHead(200, { "content-type": "text/html" }).end(
37
+ "<html><head><link rel=\"stylesheet\" href=\"" + styleUrl + "\"></head><body><h2>styled</h2></body></html>");
38
+ });
39
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
40
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
41
+ try {
42
+ const outputPath = join(directory, "out.html");
43
+ const url = "http://localhost:" + server.address().port + "/top.html";
44
+ await execFileAsync(process.execPath, [
45
+ "single-file-node.js", url, outputPath,
46
+ "--http-header", "x-test-header=yes"
47
+ ], { cwd: cliDirectory });
48
+ const content = await readFile(outputPath, "utf8");
49
+ assert.ok(!requests.some(({ method }) => method === "OPTIONS"), "the extra HTTP header made the request send a preflight");
50
+ // the browser is what fetched the resource, the backend fetch sends no
51
+ // sec-fetch-mode and would hide a preflight failure behind a working save
52
+ const corsRequest = requests.find(({ headers }) => headers["sec-fetch-mode"] === "cors");
53
+ assert.ok(corsRequest, "the resource was not fetched by the browser");
54
+ assert.equal(corsRequest.headers["x-test-header"], "yes", "the extra HTTP header was not sent");
55
+ assert.ok(content.includes(STYLE), "the cross-origin stylesheet was not inlined");
56
+ } finally {
57
+ await rm(directory, { recursive: true });
58
+ server.close();
59
+ resourceServer.close();
60
+ }
61
+ });
@@ -0,0 +1,89 @@
1
+ /* global URL */
2
+
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { createServer } from "node:http";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import process from "node:process";
13
+
14
+ const execFileAsync = promisify(execFile);
15
+ const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+
17
+ // the worker fetches a marked URL instead of passing the request through, so a
18
+ // request reaching the server proves the service worker is the one that made it
19
+ const SERVICE_WORKER = `self.addEventListener("install", () => self.skipWaiting());
20
+ self.addEventListener("activate", event => event.waitUntil(self.clients.claim()));
21
+ self.addEventListener("fetch", event => {
22
+ const url = new URL(event.request.url);
23
+ if (url.pathname.endsWith(".css")) {
24
+ url.searchParams.set("via", "sw");
25
+ event.respondWith(fetch(url.href));
26
+ }
27
+ });`;
28
+
29
+ const PAGE = `<html><head></head><body><h2>styled</h2><h3>tracked</h3>
30
+ <script>
31
+ navigator.serviceWorker.register("/sw.js").then(async () => {
32
+ await navigator.serviceWorker.ready;
33
+ if (!navigator.serviceWorker.controller) {
34
+ await new Promise(resolve => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }));
35
+ }
36
+ for (const href of ["/styled.css", "/tracker.css"]) {
37
+ await new Promise(resolve => {
38
+ const link = document.createElement("link");
39
+ link.rel = "stylesheet";
40
+ link.href = href;
41
+ link.onload = link.onerror = resolve;
42
+ document.head.appendChild(link);
43
+ });
44
+ }
45
+ });
46
+ </script></body></html>`;
47
+
48
+ test("network options reach the requests made by a service worker", { timeout: 120000 }, async () => {
49
+ const STYLE = "rgb(7,7,7)";
50
+ const requests = [];
51
+ const server = createServer((request, response) => {
52
+ const url = new URL(request.url, "http://localhost");
53
+ requests.push({
54
+ path: url.pathname,
55
+ via: url.searchParams.get("via") || "-",
56
+ header: request.headers["x-test-header"] || "-"
57
+ });
58
+ if (url.pathname === "/sw.js") {
59
+ response.writeHead(200, { "content-type": "text/javascript" }).end(SERVICE_WORKER);
60
+ } else if (url.pathname === "/styled.css") {
61
+ response.writeHead(200, { "content-type": "text/css" }).end("h2 { color: " + STYLE + "; }");
62
+ } else if (url.pathname === "/tracker.css") {
63
+ response.writeHead(200, { "content-type": "text/css" }).end("h3 { color: rgb(9,9,9); }");
64
+ } else {
65
+ response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
66
+ }
67
+ });
68
+ await new Promise(resolve => server.listen(0, "localhost", resolve));
69
+ const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
70
+ try {
71
+ const outputPath = join(directory, "out.html");
72
+ const url = "http://localhost:" + server.address().port + "/top.html";
73
+ await execFileAsync(process.execPath, [
74
+ "single-file-node.js", url, outputPath,
75
+ "--http-header", "x-test-header=yes",
76
+ "--blocked-URL-pattern", "tracker",
77
+ "--browser-wait-delay", "3000"
78
+ ], { cwd: cliDirectory });
79
+ const content = await readFile(outputPath, "utf8");
80
+ const workerRequest = requests.find(({ path, via }) => path === "/styled.css" && via === "sw");
81
+ assert.ok(workerRequest, "the service worker never served the stylesheet, the test proves nothing");
82
+ assert.equal(workerRequest.header, "yes", "the extra HTTP header was not sent by the service worker");
83
+ assert.ok(!requests.some(({ path }) => path === "/tracker.css"), "the service worker requested a blocked URL");
84
+ assert.ok(content.includes(STYLE), "the stylesheet served by the service worker was not inlined");
85
+ } finally {
86
+ await rm(directory, { recursive: true });
87
+ server.close();
88
+ }
89
+ });
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { mkdtemp, mkdir, readdir, readFile, symlink, writeFile, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { copyProfile } from "../../lib/browser.js";
6
+ import { copyProfile, pruneProfile } from "../../lib/browser.js";
7
7
 
8
8
  async function createProfileDirectory() {
9
9
  const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
@@ -57,6 +57,43 @@ test("copying a profile leaves the source directory unmodified", async () => {
57
57
  }
58
58
  });
59
59
 
60
+ test("pruning a profile deletes the caches at every depth", async () => {
61
+ const { directory, sourcePath } = await createProfileDirectory();
62
+ try {
63
+ await pruneProfile(sourcePath);
64
+ assert.deepEqual((await readdir(sourcePath)).sort(), ["Default", "Local State", "SingletonLock"]);
65
+ assert.deepEqual((await readdir(join(sourcePath, "Default"))).sort(), ["Cookies", "Local Storage"]);
66
+ } finally {
67
+ await rm(directory, { recursive: true });
68
+ }
69
+ });
70
+
71
+ test("pruning a profile keeps the session data", async () => {
72
+ const { directory, sourcePath } = await createProfileDirectory();
73
+ try {
74
+ await pruneProfile(sourcePath);
75
+ assert.equal(await readFile(join(sourcePath, "Local State"), "utf8"), "state");
76
+ assert.equal(await readFile(join(sourcePath, "Default", "Cookies"), "utf8"), "cookies");
77
+ assert.equal(await readFile(join(sourcePath, "Default", "Local Storage", "leveldb"), "utf8"), "storage");
78
+ } finally {
79
+ await rm(directory, { recursive: true });
80
+ }
81
+ });
82
+
83
+ test("pruning a profile leaves nothing a copy would have taken", async () => {
84
+ const { directory, sourcePath, destinationPath } = await createProfileDirectory();
85
+ try {
86
+ await mkdir(destinationPath);
87
+ await copyProfile(sourcePath, destinationPath);
88
+ await pruneProfile(sourcePath);
89
+ const prunedEntries = (await readdir(sourcePath, { recursive: true })).sort();
90
+ const copiedEntries = (await readdir(destinationPath, { recursive: true })).sort();
91
+ assert.deepEqual(prunedEntries.filter(entry => entry != "SingletonLock"), copiedEntries);
92
+ } finally {
93
+ await rm(directory, { recursive: true });
94
+ }
95
+ });
96
+
60
97
  test("copying a missing profile is reported with its path", async () => {
61
98
  const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
62
99
  try {
@@ -24,6 +24,25 @@ test("help is displayed with an exit code of 0", async () => {
24
24
  assert.ok(stdout.includes("--browser-executable-path"));
25
25
  });
26
26
 
27
+ test("every option of the help is named in lowercase", async () => {
28
+ const { stdout } = await runCli(["--help"]);
29
+ const optionNames = Array.from(stdout.matchAll(/^\s+--(\S+):/gm)).map(([, name]) => name);
30
+ assert.ok(optionNames.length > 100);
31
+ assert.deepEqual(optionNames.filter(name => name != name.toLowerCase()), []);
32
+ });
33
+
34
+ test("a byte order mark that cannot be written is reported as a warning", async () => {
35
+ const { stderr } = await runCli(["--include-bom", "--compress-content"]);
36
+ assert.ok(stderr.includes("Warning: --include-bom is ignored"));
37
+ });
38
+
39
+ test("a byte order mark that can be written is not reported", async () => {
40
+ const { stderr } = await runCli(["--include-bom", "--compress-content", "--extract-data-from-page=false"]);
41
+ assert.equal(stderr.includes("--include-bom is ignored"), false);
42
+ const plain = await runCli(["--include-bom"]);
43
+ assert.equal(plain.stderr.includes("--include-bom is ignored"), false);
44
+ });
45
+
27
46
  test("a missing url is reported as an error", async () => {
28
47
  const { code, stderr } = await runCli(["--browser-executable-path", "/path/to/chrome"]);
29
48
  assert.equal(code, 1);