single-file-cli 2.5.0 → 2.6.1
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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +7 -7
- package/build.sh +1 -1
- package/deno.json +1 -1
- package/lib/browser.js +23 -2
- package/lib/cdp-client.js +91 -30
- package/lib/single-file-archive.js +5 -5
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/options.js +26 -12
- package/package.json +1 -1
- package/single-file-cli-api.js +18 -3
- package/single-file-launcher.js +13 -1
- package/test/e2e/backend-fetch.test.js +92 -0
- package/test/e2e/blocked-url-pattern.test.js +54 -0
- package/test/e2e/http-header.test.js +61 -0
- package/test/e2e/service-worker.test.js +89 -0
- package/test/unit/browser-profile.test.js +38 -1
- package/test/unit/cli-usage.test.js +19 -0
- package/test/unit/options.test.js +5 -0
- /package/{dev-build.sh → build-dev.sh} +0 -0
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.
|
|
1
|
+
export const version = "2.6.1";
|
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-
|
|
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-
|
|
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-
|
|
116
|
-
"compress-
|
|
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,6 +129,7 @@ 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" },
|
|
@@ -138,10 +139,10 @@ const OPTIONS_INFO = [{
|
|
|
138
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" },
|
|
139
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" },
|
|
140
141
|
"embed-screenshot-options": { description: "Options passed to the CDP method `Page.captureScreenshot()` given as a JSON string (e.g. { \"captureBeyondViewport\": false })", type: "string" },
|
|
141
|
-
"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" },
|
|
142
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" },
|
|
143
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" },
|
|
144
|
-
"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" }
|
|
145
146
|
}, {
|
|
146
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}" },
|
|
147
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" },
|
|
@@ -156,12 +157,12 @@ const OPTIONS_INFO = [{
|
|
|
156
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 },
|
|
157
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" },
|
|
158
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" },
|
|
159
|
-
"crawl-remove-
|
|
160
|
+
"crawl-remove-url-fragment": { key: "crawlRemoveURLFragment", description: "Remove URL fragments found in links", type: "boolean", defaultValue: true },
|
|
160
161
|
"crawl-save-session": { description: "Name of the file where to save the state of the session", type: "string" },
|
|
161
162
|
"crawl-sync-session": { description: "Name of the file where to load and save the state of the session", type: "string" },
|
|
162
163
|
"crawl-max-depth": { description: "Max depth when crawling pages found in internal and external links (0: infinite)", type: "number", defaultValue: 1 },
|
|
163
164
|
"crawl-external-links-max-depth": { description: "Max depth when crawling pages found in external links (0: infinite)", type: "number", defaultValue: 1 },
|
|
164
|
-
"crawl-replace-
|
|
165
|
+
"crawl-replace-urls": { key: "crawlReplaceURLs", description: "Replace URLs of saved pages with relative paths of saved pages on the filesystem", type: "boolean" },
|
|
165
166
|
"crawl-rewrite-rule": { description: "Rewrite rule used to rewrite URLs of crawled pages", type: "string[]" },
|
|
166
167
|
"crawl-save-archive": { description: "Save all the crawled pages into a single (self-extracting) ZIP file, requires --compress-content", type: "boolean" },
|
|
167
168
|
"crawl-save-archive-dedup": { description: "Deduplicate identical resources shared between pages when using --crawl-save-archive", type: "boolean" },
|
|
@@ -185,10 +186,10 @@ const OPTIONS_INFO = [{
|
|
|
185
186
|
"infobar-position-right": { description: "Position the infobar at the right of the page", type: "string", defaultValue: "16px" },
|
|
186
187
|
"infobar-position-left": { description: "Position the infobar at the left of the page", type: "string", defaultValue: "" },
|
|
187
188
|
}, {
|
|
188
|
-
"include-
|
|
189
|
-
"insert-meta-
|
|
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 },
|
|
190
191
|
"remove-saved-date": { description: "Remove saved date metadata in HTML header", type: "boolean" },
|
|
191
|
-
"save-original-
|
|
192
|
+
"save-original-urls": { key: "saveOriginalURLs", description: "Save the original URLS in the embedded contents", type: "boolean" },
|
|
192
193
|
"insert-single-file-comment": { description: "Insert a comment in the HTML header with the URL of the page", type: "boolean", defaultValue: true },
|
|
193
194
|
"resolve-links": { description: "Resolve link URLs to absolute URLs", type: "boolean", defaultValue: true }
|
|
194
195
|
}, {
|
|
@@ -216,7 +217,7 @@ const OPTIONS_INFO = [{
|
|
|
216
217
|
const CRAWL_LINKS_DEPENDENT_OPTIONS = {
|
|
217
218
|
crawlInnerLinksOnly: "--crawl-inner-links-only",
|
|
218
219
|
crawlNoParent: "--crawl-no-parent",
|
|
219
|
-
crawlRemoveURLFragment: "--crawl-remove-
|
|
220
|
+
crawlRemoveURLFragment: "--crawl-remove-url-fragment",
|
|
220
221
|
crawlMaxDepth: "--crawl-max-depth",
|
|
221
222
|
crawlExternalLinksMaxDepth: "--crawl-external-links-max-depth",
|
|
222
223
|
crawlRewriteRules: "--crawl-rewrite-rule"
|
|
@@ -329,6 +330,14 @@ function getOptions() {
|
|
|
329
330
|
.filter(optionKey => explicitOptions[optionKey] !== undefined)
|
|
330
331
|
.forEach(optionKey => errorMessages.push(`${CRAWL_LINKS_DEPENDENT_OPTIONS[optionKey]} requires --crawl-links`));
|
|
331
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
|
+
}
|
|
332
341
|
if (errorMessages.length) {
|
|
333
342
|
printUsage();
|
|
334
343
|
errorMessages.forEach(message => console.error(`Error: ${message}`)); // eslint-disable-line no-console
|
|
@@ -545,6 +554,11 @@ function parseArgs(args, setDefaultValues = true) {
|
|
|
545
554
|
|
|
546
555
|
function getOptionKey(optionKeyName, optionInfo) {
|
|
547
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
|
+
}
|
|
548
562
|
const optionName = optionInfo.alias || optionKeyName;
|
|
549
563
|
if (isArray(optionInfo.type)) {
|
|
550
564
|
return kebabToCamelCase(optionName + "s");
|
package/package.json
CHANGED
package/single-file-cli-api.js
CHANGED
|
@@ -92,9 +92,11 @@ async function initialize(options) {
|
|
|
92
92
|
if (!options.compressContent) {
|
|
93
93
|
throw new Error("--crawl-save-archive requires --compress-content");
|
|
94
94
|
}
|
|
95
|
-
if (options.embedPdf || options.
|
|
96
|
-
|
|
97
|
-
|
|
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");
|
|
98
100
|
}
|
|
99
101
|
archiveTempDirectory = await makeTempDir();
|
|
100
102
|
}
|
|
@@ -208,6 +210,8 @@ async function savePagesArchive(options) {
|
|
|
208
210
|
extractDataFromPage: options.extractDataFromPage,
|
|
209
211
|
preventAppendedData: options.preventAppendedData,
|
|
210
212
|
declareAppendedData: options.declareAppendedData,
|
|
213
|
+
embeddedPdf: options.embeddedPdf,
|
|
214
|
+
embeddedImage: options.embeddedImage,
|
|
211
215
|
includeBOM: options.includeBOM,
|
|
212
216
|
insertMetaCSP: options.insertMetaCSP,
|
|
213
217
|
insertCanonicalLink: options.insertCanonicalLink,
|
|
@@ -248,6 +252,10 @@ async function runNextTask() {
|
|
|
248
252
|
taskOptions.selfExtractingArchive = false;
|
|
249
253
|
taskOptions.extractDataFromPage = false;
|
|
250
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;
|
|
251
259
|
taskOptions.archiveFilename = archiveTempDirectory + "/" + tasks.indexOf(task) + ".zip";
|
|
252
260
|
}
|
|
253
261
|
task.status = STATE_PROCESSING;
|
|
@@ -369,6 +377,13 @@ async function capturePage(options) {
|
|
|
369
377
|
options.zipScript = getZipScriptSource();
|
|
370
378
|
const pageData = await backend.getPageData(options);
|
|
371
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
|
+
}
|
|
372
387
|
if (options.consoleMessagesFile && pageData.consoleMessages) {
|
|
373
388
|
await writeTextFile(options.consoleMessagesFile, JSON.stringify(pageData.consoleMessages, null, 2));
|
|
374
389
|
}
|
package/single-file-launcher.js
CHANGED
|
@@ -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,92 @@
|
|
|
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, 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("the backend fetch presents the same user agent as the browser", { timeout: 120000 }, async () => {
|
|
18
|
+
const requests = [];
|
|
19
|
+
// no access-control-allow-origin, so the browser sends the request, refuses the
|
|
20
|
+
// response and the page falls back to the fetch made outside the browser
|
|
21
|
+
const resourceServer = createServer((request, response) => {
|
|
22
|
+
const { pathname } = new URL(request.url, "http://127.0.0.1");
|
|
23
|
+
requests.push({ headers: request.headers });
|
|
24
|
+
if (pathname === "/cors.css") {
|
|
25
|
+
response.writeHead(200, { "content-type": "text/css" }).end("h2 { color: rgb(11,22,33); }");
|
|
26
|
+
} else {
|
|
27
|
+
response.writeHead(404).end();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
await new Promise(resolve => resourceServer.listen(0, "127.0.0.1", resolve));
|
|
31
|
+
const styleUrl = "http://127.0.0.1:" + resourceServer.address().port + "/cors.css";
|
|
32
|
+
const server = createServer((request, response) => {
|
|
33
|
+
response.writeHead(200, { "content-type": "text/html" }).end(
|
|
34
|
+
"<html><head><link rel=\"stylesheet\" href=\"" + styleUrl + "\"></head><body><h2>styled</h2></body></html>");
|
|
35
|
+
});
|
|
36
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
37
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
38
|
+
try {
|
|
39
|
+
const outputPath = join(directory, "out.html");
|
|
40
|
+
const url = "http://localhost:" + server.address().port + "/top.html";
|
|
41
|
+
await execFileAsync(process.execPath, ["single-file-node.js", url, outputPath], { cwd: cliDirectory });
|
|
42
|
+
// the fetch made outside the browser sends no sec-fetch-dest; it does send
|
|
43
|
+
// sec-fetch-mode, which node sets on every request, so that one tells nothing
|
|
44
|
+
const browserRequest = requests.find(({ headers }) => headers["sec-fetch-dest"] !== undefined);
|
|
45
|
+
const backendRequest = requests.find(({ headers }) => headers["sec-fetch-dest"] === undefined);
|
|
46
|
+
assert.ok(browserRequest, "the resource was not fetched by the browser");
|
|
47
|
+
assert.ok(backendRequest, "the resource was not fetched outside the browser");
|
|
48
|
+
assert.equal(backendRequest.headers["user-agent"], browserRequest.headers["user-agent"],
|
|
49
|
+
"the backend fetch presented another user agent than the browser");
|
|
50
|
+
} finally {
|
|
51
|
+
await rm(directory, { recursive: true });
|
|
52
|
+
server.close();
|
|
53
|
+
resourceServer.close();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the backend fetch presents the user agent given on the command line", { timeout: 120000 }, async () => {
|
|
58
|
+
const USER_AGENT = "SingleFileProbe/1.0";
|
|
59
|
+
const requests = [];
|
|
60
|
+
const resourceServer = createServer((request, response) => {
|
|
61
|
+
const { pathname } = new URL(request.url, "http://127.0.0.1");
|
|
62
|
+
requests.push({ headers: request.headers });
|
|
63
|
+
if (pathname === "/cors.css") {
|
|
64
|
+
response.writeHead(200, { "content-type": "text/css" }).end("h2 { color: rgb(11,22,33); }");
|
|
65
|
+
} else {
|
|
66
|
+
response.writeHead(404).end();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
await new Promise(resolve => resourceServer.listen(0, "127.0.0.1", resolve));
|
|
70
|
+
const styleUrl = "http://127.0.0.1:" + resourceServer.address().port + "/cors.css";
|
|
71
|
+
const server = createServer((request, response) => {
|
|
72
|
+
response.writeHead(200, { "content-type": "text/html" }).end(
|
|
73
|
+
"<html><head><link rel=\"stylesheet\" href=\"" + styleUrl + "\"></head><body><h2>styled</h2></body></html>");
|
|
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 outputPath = join(directory, "out.html");
|
|
79
|
+
const url = "http://localhost:" + server.address().port + "/top.html";
|
|
80
|
+
await execFileAsync(process.execPath, [
|
|
81
|
+
"single-file-node.js", url, outputPath,
|
|
82
|
+
"--user-agent", USER_AGENT
|
|
83
|
+
], { cwd: cliDirectory });
|
|
84
|
+
assert.ok(requests.length, "the resource was not fetched");
|
|
85
|
+
assert.ok(requests.every(({ headers }) => headers["user-agent"] === USER_AGENT),
|
|
86
|
+
"a request presented another user agent: " + JSON.stringify(requests.map(({ headers }) => headers["user-agent"])));
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(directory, { recursive: true });
|
|
89
|
+
server.close();
|
|
90
|
+
resourceServer.close();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -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,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);
|
|
@@ -21,6 +21,11 @@ test("option names are canonicalized regardless of case", () => {
|
|
|
21
21
|
assert.equal("compressHtml" in options, false);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
test("the compression of the entries can be disabled", () => {
|
|
25
|
+
assert.equal(parse(["--disable-compression"]).disableCompression, true);
|
|
26
|
+
assert.equal(parse([]).disableCompression, undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
24
29
|
test("aliases map to the canonical option", () => {
|
|
25
30
|
assert.equal(parse(["--error-file", "errors.txt"]).errorsFile, "errors.txt");
|
|
26
31
|
assert.equal(parse(["--errors-file", "errors.txt"]).errorsFile, "errors.txt");
|
|
File without changes
|