single-file-cli 2.7.2 → 2.9.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/README.MD +8 -2
- package/build.sh +6 -3
- package/deno.json +1 -1
- package/lib/bidi-client.js +1221 -0
- package/lib/bidi.js +141 -0
- package/lib/browser.js +14 -209
- package/lib/cdp-client.js +11 -11
- package/lib/chromium.js +225 -0
- package/lib/constants.js +27 -4
- package/lib/deno-polyfill.js +5 -0
- package/lib/firefox.js +182 -0
- 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 +20 -2
- package/package.json +2 -1
- package/single-file-cli-api.js +11 -5
- package/single-file-launcher.js +3 -3
- package/test/e2e/automation-detection.test.js +4 -3
- package/test/e2e/fidelity.test.js +2 -2
- package/test/e2e/html-at-media-url.test.js +96 -0
- package/test/e2e/password.test.js +47 -0
- package/test/e2e/proxy-auth.test.js +53 -0
- package/test/e2e/service-worker.test.js +2 -2
- package/test/fidelity/browser.js +3 -3
- package/test/target.js +5 -1
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.
|
|
1
|
+
export const version = "2.9.0";
|
package/options.js
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
import { version } from "./lib/version.js";
|
|
25
25
|
import { Deno } from "./lib/deno-polyfill.js";
|
|
26
26
|
|
|
27
|
-
const { args, build, exit } = Deno;
|
|
27
|
+
const { args, build, env, exit } = Deno;
|
|
28
|
+
const BROWSER_ENGINE_ENVIRONMENT_VARIABLE = "SINGLE_FILE_BROWSER_ENGINE";
|
|
29
|
+
const BROWSER_ENGINES = ["chromium", "firefox"];
|
|
28
30
|
|
|
29
31
|
const USAGE_TEXT = `single-file [url] [output]
|
|
30
32
|
|
|
@@ -56,6 +58,7 @@ const OPTIONS_INFO = [{
|
|
|
56
58
|
"browser-server": { description: "Server to connect to", type: "string", alias: "browser-remote-debugging-url" },
|
|
57
59
|
"browser-headless": { description: "Run the browser in headless mode", type: "boolean", defaultValue: true },
|
|
58
60
|
"browser-executable-path": { description: "Path to chrome/chromium executable", type: "string" },
|
|
61
|
+
"browser-engine": { description: "Browser engine to use (chromium, firefox). Firefox is driven through WebDriver BiDi and has limitations: --create-browser-profile is not available, --browser-mobile-emulation only sets the viewport size and scale factor, --emulate-media-feature and the pause of --browser-debug are ignored, the requests of service workers do not get the blocked URL patterns and extra HTTP headers, and navigator.webdriver is true in the page", type: "string", defaultValue: "chromium" },
|
|
59
62
|
"browser-profile": { description: "Path of the browser profile directory to use, e.g. to save pages requiring a logged-in session (see --create-browser-profile). The directory is copied before starting the browser and is left unmodified.", type: "string" },
|
|
60
63
|
"browser-width": { description: "Width of the browser viewport in pixels", type: "number", defaultValue: 1280 },
|
|
61
64
|
"browser-height": { description: "Height of the browser viewport in pixels", type: "number", defaultValue: 720 },
|
|
@@ -126,6 +129,7 @@ const OPTIONS_INFO = [{
|
|
|
126
129
|
"move-styles-in-head": { description: "Move style elements outside the head element into the head element", type: "boolean" },
|
|
127
130
|
"group-duplicate-images": { description: "Group duplicate images into CSS custom properties", type: "boolean", defaultValue: true },
|
|
128
131
|
"max-size-duplicate-images": { description: "Maximum size in bytes of duplicate images stored as CSS custom properties", type: "number", defaultValue: 512 * 1024 },
|
|
132
|
+
"image-reduction-factor": { description: "Divide the dimensions of PNG, JPEG and WEBP images by this factor in order to reduce the size of the page (e.g. 2 halves them)", type: "number", defaultValue: 1 },
|
|
129
133
|
"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
134
|
}, {
|
|
131
135
|
"compress-content": { description: "Create a ZIP file instead of an HTML file", type: "boolean" },
|
|
@@ -293,6 +297,15 @@ function applySettings(options, settings, explicitOptions = parseArgs(Array.from
|
|
|
293
297
|
|
|
294
298
|
function getOptions() {
|
|
295
299
|
const { positionals, options, invalidOptions } = parseArgs(Array.from(args));
|
|
300
|
+
const explicitOptions = parseArgs(Array.from(args), false).options;
|
|
301
|
+
const environmentBrowserEngine = env.get(BROWSER_ENGINE_ENVIRONMENT_VARIABLE);
|
|
302
|
+
if (explicitOptions.browserEngine === undefined && environmentBrowserEngine) {
|
|
303
|
+
if (BROWSER_ENGINES.includes(environmentBrowserEngine)) {
|
|
304
|
+
options.browserEngine = environmentBrowserEngine;
|
|
305
|
+
} else {
|
|
306
|
+
invalidOptions.push({ name: "browser-engine", value: environmentBrowserEngine });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
296
309
|
const unknownOptions = positionals.filter(positional => positional.startsWith("--"));
|
|
297
310
|
const urls = positionals.filter(positional => !positional.startsWith("--"));
|
|
298
311
|
if (options.help) {
|
|
@@ -315,6 +328,9 @@ function getOptions() {
|
|
|
315
328
|
errorMessages.push(`Unexpected arguments: ${urls.slice(2).join(", ")}`);
|
|
316
329
|
}
|
|
317
330
|
if (options.createBrowserProfile) {
|
|
331
|
+
if (options.browserEngine == "firefox") {
|
|
332
|
+
errorMessages.push("--create-browser-profile is not supported with --browser-engine firefox");
|
|
333
|
+
}
|
|
318
334
|
if (options.browserProfile) {
|
|
319
335
|
errorMessages.push("--create-browser-profile cannot be used with --browser-profile, it already takes the path of the profile directory");
|
|
320
336
|
}
|
|
@@ -325,7 +341,6 @@ function getOptions() {
|
|
|
325
341
|
errorMessages.push("--browser-profile cannot be used with --browser-server");
|
|
326
342
|
}
|
|
327
343
|
if (!options.crawlLinks) {
|
|
328
|
-
const explicitOptions = parseArgs(Array.from(args), false).options;
|
|
329
344
|
Object.keys(CRAWL_LINKS_DEPENDENT_OPTIONS)
|
|
330
345
|
.filter(optionKey => explicitOptions[optionKey] !== undefined)
|
|
331
346
|
.forEach(optionKey => errorMessages.push(`${CRAWL_LINKS_DEPENDENT_OPTIONS[optionKey]} requires --crawl-links`));
|
|
@@ -482,6 +497,9 @@ function parseArgs(args, setDefaultValues = true) {
|
|
|
482
497
|
result.options.browserServer = result.options.browserRemoteDebuggingUrl;
|
|
483
498
|
delete result.options.browserRemoteDebuggingUrl;
|
|
484
499
|
}
|
|
500
|
+
if (result.options.browserEngine !== undefined && !BROWSER_ENGINES.includes(result.options.browserEngine)) {
|
|
501
|
+
invalidOptions.push({ name: "browser-engine", value: result.options.browserEngine });
|
|
502
|
+
}
|
|
485
503
|
if (result.options.filenameReplacedCharacters) {
|
|
486
504
|
const filenameReplacedCharacters = result.options.filenameReplacedCharacters;
|
|
487
505
|
result.options.filenameReplacedCharacters = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "single-file-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "SingleFile CLI",
|
|
5
5
|
"author": "Gildas Lormeau",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"test": "node --test --test-concurrency=2 \"test/**/*.test.js\"",
|
|
19
19
|
"test:dev": "SINGLE_FILE_TARGET=dev node --test --test-concurrency=2 \"test/**/*.test.js\"",
|
|
20
|
+
"test:firefox": "SINGLE_FILE_BROWSER_ENGINE=firefox node --test --test-concurrency=2 \"test/**/*.test.js\"",
|
|
20
21
|
"lint": "eslint .",
|
|
21
22
|
"update-core": "bash update-core.sh"
|
|
22
23
|
},
|
package/single-file-cli-api.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
/* global URL */
|
|
25
25
|
|
|
26
26
|
import { Buffer } from "node:buffer";
|
|
27
|
-
import * as
|
|
27
|
+
import * as cdpBackend from "./lib/cdp-client.js";
|
|
28
|
+
import * as bidiBackend from "./lib/bidi-client.js";
|
|
28
29
|
import { getZipScriptSource } from "./lib/single-file-script.js";
|
|
29
30
|
import { createPagesArchive } from "./lib/single-file-archive.js";
|
|
30
31
|
import { Deno, path } from "./lib/deno-polyfill.js";
|
|
@@ -61,9 +62,13 @@ const STATE_PROCESSING = "processing";
|
|
|
61
62
|
const STATE_PROCESSED = "processed";
|
|
62
63
|
|
|
63
64
|
const { readTextFile, writeTextFile, readFile, writeFile, stdout, mkdir, makeTempDir, remove, stat, errors } = Deno;
|
|
64
|
-
let tasks = [], maxParallelWorkers, sessionFilename, archiveTempDirectory, errorCount = 0;
|
|
65
|
+
let backend = cdpBackend, tasks = [], maxParallelWorkers, sessionFilename, archiveTempDirectory, errorCount = 0;
|
|
65
66
|
|
|
66
|
-
export { initialize };
|
|
67
|
+
export { initialize, closeBrowser };
|
|
68
|
+
|
|
69
|
+
async function closeBrowser() {
|
|
70
|
+
await backend.closeBrowser();
|
|
71
|
+
}
|
|
67
72
|
|
|
68
73
|
async function initialize(options) {
|
|
69
74
|
options = Object.assign({}, DEFAULT_OPTIONS, options);
|
|
@@ -101,11 +106,12 @@ async function initialize(options) {
|
|
|
101
106
|
archiveTempDirectory = await makeTempDir();
|
|
102
107
|
}
|
|
103
108
|
maxParallelWorkers = options.maxParallelWorkers || 8;
|
|
109
|
+
backend = options.browserEngine == "firefox" ? bidiBackend : cdpBackend;
|
|
104
110
|
try {
|
|
105
111
|
await backend.initialize(options);
|
|
106
112
|
} catch (error) {
|
|
107
113
|
if (error instanceof errors.NotFound) {
|
|
108
|
-
let message = "Chromium executable not found. ";
|
|
114
|
+
let message = (options.browserEngine == "firefox" ? "Firefox" : "Chromium") + " executable not found. ";
|
|
109
115
|
if (options.browserExecutablePath) {
|
|
110
116
|
message += "Make sure --browser-executable-path is correct.";
|
|
111
117
|
} else {
|
|
@@ -184,7 +190,7 @@ async function finish(options) {
|
|
|
184
190
|
}
|
|
185
191
|
}
|
|
186
192
|
}
|
|
187
|
-
if (!options.browserDebug
|
|
193
|
+
if (!options.browserDebug) {
|
|
188
194
|
await backend.closeBrowser();
|
|
189
195
|
}
|
|
190
196
|
return errorCount;
|
package/single-file-launcher.js
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* Source.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { initialize } from "./single-file-cli-api.js";
|
|
25
|
-
import {
|
|
24
|
+
import { initialize, closeBrowser } from "./single-file-cli-api.js";
|
|
25
|
+
import { createBrowserProfile, getChromiumOptions } from "./lib/chromium.js";
|
|
26
26
|
import { Deno } from "./lib/deno-polyfill.js";
|
|
27
27
|
import { getOptions, applySettings, parseUrlsFile } from "./options.js";
|
|
28
28
|
|
|
@@ -93,7 +93,7 @@ async function run() {
|
|
|
93
93
|
async function saveBrowserProfile(options) {
|
|
94
94
|
const profileDirectory = options.createBrowserProfile;
|
|
95
95
|
console.error(`Log in to the website in the browser window, then quit the browser${QUIT_BROWSER_HINT} to save the profile.`); // eslint-disable-line no-console
|
|
96
|
-
await createBrowserProfile(Object.assign(
|
|
96
|
+
await createBrowserProfile(Object.assign(getChromiumOptions(options), { profile: profileDirectory, startUrl: options.url }));
|
|
97
97
|
console.error(`Profile saved, use it with --browser-profile ${JSON.stringify(profileDirectory)}.`); // eslint-disable-line no-console
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -7,11 +7,11 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import process from "node:process";
|
|
10
|
-
import { cliDirectory } from "../target.js";
|
|
10
|
+
import { cliDirectory, firefox } from "../target.js";
|
|
11
11
|
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
|
|
14
|
-
test("pages are not captured as controlled by automation", { timeout: 120000 }, async () => {
|
|
14
|
+
test("pages are not captured as controlled by automation", { timeout: 120000, skip: firefox && "navigator.webdriver is always true under the Firefox remote agent" }, async () => {
|
|
15
15
|
const server = createServer((_, response) => response
|
|
16
16
|
.writeHead(200, { "content-type": "text/html" })
|
|
17
17
|
.end("<html><head><title>Automation</title></head><body><p id=result></p>" +
|
|
@@ -66,7 +66,8 @@ test("pages are not captured with a headless user agent", { timeout: 120000 }, a
|
|
|
66
66
|
assert.ok(scriptAgent, "missing the user agent read in the page");
|
|
67
67
|
assert.ok(!headerAgent[1].includes("Headless"), "unexpected headless token in the user agent sent to the server: " + headerAgent[1]);
|
|
68
68
|
assert.ok(!scriptAgent[1].includes("Headless"), "unexpected headless token in the user agent read in the page: " + scriptAgent[1]);
|
|
69
|
-
|
|
69
|
+
const engineToken = firefox ? "Firefox/" : "Chrome/";
|
|
70
|
+
assert.ok(scriptAgent[1].includes(engineToken), "expected a user agent naming " + engineToken + ", got: " + scriptAgent[1]);
|
|
70
71
|
} finally {
|
|
71
72
|
await rm(directory, { recursive: true });
|
|
72
73
|
server.close();
|
|
@@ -24,7 +24,7 @@ import { tmpdir } from "node:os";
|
|
|
24
24
|
import { join, dirname } from "node:path";
|
|
25
25
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
26
26
|
import process from "node:process";
|
|
27
|
-
import { cliDirectory, importLibModule, useDevBuild } from "../target.js";
|
|
27
|
+
import { cliDirectory, importLibModule, useDevBuild, firefox } from "../target.js";
|
|
28
28
|
import { openBrowser } from "../fidelity/browser.js";
|
|
29
29
|
import { startServer } from "../fidelity/server.js";
|
|
30
30
|
const { configure, ZipReader, Uint8ArrayReader, TextWriter } = await importLibModule("single-file-archive.js");
|
|
@@ -134,7 +134,7 @@ function getDeclaredFontFamilies(saved) {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
test("a saved page keeps the fonts declared inside a frame it cannot read", options, async () => {
|
|
137
|
+
test("a saved page keeps the fonts declared inside a frame it cannot read", { ...options, skip: skip || (firefox && "the font declared by a sandboxed srcdoc frame is lost on Firefox, see the SingleFile backlog") }, async () => {
|
|
138
138
|
const { comparison, noise } = await compareSaveWithSource("frame-fonts", []);
|
|
139
139
|
assertNoWorseThanNoise(comparison, noise);
|
|
140
140
|
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* global URL */
|
|
2
|
+
|
|
3
|
+
// A misconfigured server answers a font or image URL with an HTML error page and a 200 status
|
|
4
|
+
// (money.rediff.com does it for its six Roboto faces). The fetcher used to trust the declared type,
|
|
5
|
+
// so a plain save embedded the HTML as src:url(data:text/html;base64,...) inside the @font-face,
|
|
6
|
+
// while --compress-content dropped it because only that path tests the bytes with FontFace. An
|
|
7
|
+
// HTML body at a media URL is never a usable resource, so both modes now treat it like a 404.
|
|
8
|
+
//
|
|
9
|
+
// The controls are a real font and a real image on the same page: they must still be embedded.
|
|
10
|
+
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import { Buffer } from "node:buffer";
|
|
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 FONT_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "fidelity", "pages", "fonts", "block.ttf");
|
|
28
|
+
const RED_DOT = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64");
|
|
29
|
+
const ERROR_PAGE = "<!doctype html><html><head><title>Not found</title></head><body><h1>Not found</h1></body></html>";
|
|
30
|
+
const PAGE = "<html><head><style>" +
|
|
31
|
+
"@font-face{font-family:\"Fake\";src:url(/fonts/fake.woff2) format(\"woff2\")}" +
|
|
32
|
+
"@font-face{font-family:\"Real\";src:url(/fonts/block.ttf) format(\"truetype\")}" +
|
|
33
|
+
"h1{font-family:\"Real\",serif}p{font-family:\"Fake\",serif}" +
|
|
34
|
+
"</style></head><body><h1>Head</h1><p>Body</p>" +
|
|
35
|
+
"<img id=\"broken\" src=\"/images/photo.png\"><img id=\"kept\" src=\"/images/dot.png\"></body></html>";
|
|
36
|
+
|
|
37
|
+
const capturePromises = new Map();
|
|
38
|
+
|
|
39
|
+
test("an HTML page served at a font URL is dropped from a plain save", { timeout: TEST_TIMEOUT }, async () => {
|
|
40
|
+
const content = (await getCaptureResult(false)).toString("utf8");
|
|
41
|
+
assert.ok(!content.includes("data:text/html"), "an HTML body was embedded as a resource");
|
|
42
|
+
assert.match(content, /font-family:\s*"?Fake"?;\s*src:\s*[;}]/, "the unusable font source was not removed");
|
|
43
|
+
assert.match(content, /font-family:\s*"?Real"?;\s*src:\s*url\("?data:font\/ttf;base64,/, "the real font was not embedded");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("an HTML page served at an image URL is treated like a missing image in a plain save", { timeout: TEST_TIMEOUT }, async () => {
|
|
47
|
+
const content = (await getCaptureResult(false)).toString("utf8");
|
|
48
|
+
assert.match(content, /id="?broken"?\s+src="?data:,"?/, "the HTML body was kept as the image source");
|
|
49
|
+
assert.match(content, /id="?kept"?\s+src="?data:image\/png;base64,/, "the real image was not embedded");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("an HTML page served at a media URL gets no archive entry", { timeout: TEST_TIMEOUT }, async () => {
|
|
53
|
+
const data = await getCaptureResult(true);
|
|
54
|
+
configure({ useWebWorkers: false });
|
|
55
|
+
const zipReader = new ZipReader(new Uint8ArrayReader(new Uint8Array(data)));
|
|
56
|
+
const entryNames = (await zipReader.getEntries()).map(entry => entry.filename);
|
|
57
|
+
assert.deepEqual(entryNames.filter(name => name.startsWith("fonts/")).length, 1, "entries: " + entryNames.join(", "));
|
|
58
|
+
assert.deepEqual(entryNames.filter(name => name.startsWith("images/")).length, 1, "entries: " + entryNames.join(", "));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function getCaptureResult(compressContent) {
|
|
62
|
+
if (!capturePromises.has(compressContent)) {
|
|
63
|
+
capturePromises.set(compressContent, runCapture(compressContent));
|
|
64
|
+
}
|
|
65
|
+
return capturePromises.get(compressContent);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function runCapture(compressContent) {
|
|
69
|
+
const font = await readFile(FONT_PATH);
|
|
70
|
+
const server = createServer((request, response) => {
|
|
71
|
+
const { pathname } = new URL(request.url, "http://localhost");
|
|
72
|
+
if (pathname === "/") {
|
|
73
|
+
response.writeHead(200, { "content-type": "text/html" }).end(PAGE);
|
|
74
|
+
} else if (pathname === "/fonts/block.ttf") {
|
|
75
|
+
response.writeHead(200, { "content-type": "font/ttf" }).end(font);
|
|
76
|
+
} else if (pathname === "/images/dot.png") {
|
|
77
|
+
response.writeHead(200, { "content-type": "image/png" }).end(RED_DOT);
|
|
78
|
+
} else {
|
|
79
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(ERROR_PAGE);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
83
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
84
|
+
try {
|
|
85
|
+
const outputPath = join(directory, compressContent ? "out.zip.html" : "out.html");
|
|
86
|
+
const args = ["single-file-node.js", "http://localhost:" + server.address().port + "/", outputPath];
|
|
87
|
+
if (compressContent) {
|
|
88
|
+
args.push("--compress-content");
|
|
89
|
+
}
|
|
90
|
+
await execFileAsync(process.execPath, args, { cwd: cliDirectory });
|
|
91
|
+
return await readFile(outputPath);
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(directory, { recursive: true });
|
|
94
|
+
server.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* global 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 } from "node:path";
|
|
11
|
+
import process from "node:process";
|
|
12
|
+
import { cliDirectory, importLibModule } from "../target.js";
|
|
13
|
+
|
|
14
|
+
const { configure, ZipReader, Uint8ArrayReader, TextWriter } = await importLibModule("single-file-archive.js");
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const PASSWORD = "s3cret";
|
|
17
|
+
const MARKER = "protected by a password";
|
|
18
|
+
|
|
19
|
+
configure({ useWebWorkers: false });
|
|
20
|
+
|
|
21
|
+
test("a password-protected archive decrypts with the password", { timeout: 120000 }, async () => {
|
|
22
|
+
const server = createServer((_, response) => response
|
|
23
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
24
|
+
.end("<html><head><title>locked</title></head><body><p>" + MARKER + "</p></body></html>"));
|
|
25
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
26
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
27
|
+
try {
|
|
28
|
+
const outputPath = join(directory, "out.html");
|
|
29
|
+
await execFileAsync(process.execPath, [
|
|
30
|
+
"single-file-node.js", "http://localhost:" + server.address().port + "/", outputPath,
|
|
31
|
+
"--compress-content", "--password", PASSWORD
|
|
32
|
+
], { cwd: cliDirectory });
|
|
33
|
+
const data = new Uint8Array(await readFile(outputPath));
|
|
34
|
+
assert.ok(!new TextDecoder().decode(data).includes(MARKER), "the page content is readable without the password");
|
|
35
|
+
const zipReader = new ZipReader(new Uint8ArrayReader(data), { extractPrependedData: true, extractAppendedData: true, password: PASSWORD });
|
|
36
|
+
const entries = await zipReader.getEntries();
|
|
37
|
+
const pageEntry = entries.find(entry => entry.filename.endsWith("index.html"));
|
|
38
|
+
assert.ok(pageEntry, "the archive has no index.html entry");
|
|
39
|
+
assert.ok(pageEntry.encrypted, "the page entry is not encrypted");
|
|
40
|
+
const content = await pageEntry.getData(new TextWriter());
|
|
41
|
+
assert.ok(content.includes(MARKER), "the page entry does not decrypt to the page");
|
|
42
|
+
await zipReader.close();
|
|
43
|
+
} finally {
|
|
44
|
+
await rm(directory, { recursive: true, force: true });
|
|
45
|
+
server.close();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { Buffer } from "node:buffer";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { cliDirectory } from "../target.js";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const USERNAME = "single";
|
|
15
|
+
const PASSWORD = "file";
|
|
16
|
+
// a name the browser cannot resolve itself, so the request can only reach the proxy;
|
|
17
|
+
// a loopback address would be bypassed by both engines
|
|
18
|
+
const PAGE_URL = "http://proxy-auth.test/page.html";
|
|
19
|
+
const MARKER = "served through the proxy";
|
|
20
|
+
const CREDENTIALS = "Basic " + Buffer.from(USERNAME + ":" + PASSWORD).toString("base64");
|
|
21
|
+
|
|
22
|
+
test("proxy credentials answer the 407 challenge of the proxy", { timeout: 120000 }, async () => {
|
|
23
|
+
const requests = [];
|
|
24
|
+
const proxy = createServer((request, response) => {
|
|
25
|
+
const authorization = request.headers["proxy-authorization"];
|
|
26
|
+
requests.push({ url: request.url, authorization });
|
|
27
|
+
if (authorization !== CREDENTIALS) {
|
|
28
|
+
response.writeHead(407, { "proxy-authenticate": "Basic realm=\"single-file\"", "content-type": "text/plain" }).end("proxy authentication required");
|
|
29
|
+
} else if (request.url === PAGE_URL) {
|
|
30
|
+
response.writeHead(200, { "content-type": "text/html" }).end("<html><head><title>proxied</title></head><body><p>" + MARKER + "</p></body></html>");
|
|
31
|
+
} else {
|
|
32
|
+
response.writeHead(404).end();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
await new Promise(resolve => proxy.listen(0, "127.0.0.1", resolve));
|
|
36
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
37
|
+
try {
|
|
38
|
+
const outputPath = join(directory, "out.html");
|
|
39
|
+
await execFileAsync(process.execPath, [
|
|
40
|
+
"single-file-node.js", PAGE_URL, outputPath,
|
|
41
|
+
"--http-proxy-server", "127.0.0.1:" + proxy.address().port,
|
|
42
|
+
"--http-proxy-username", USERNAME,
|
|
43
|
+
"--http-proxy-password", PASSWORD
|
|
44
|
+
], { cwd: cliDirectory });
|
|
45
|
+
const content = await readFile(outputPath, "utf8");
|
|
46
|
+
assert.ok(content.includes(MARKER), "the page was not fetched through the proxy");
|
|
47
|
+
assert.ok(requests.some(({ authorization }) => !authorization), "the proxy never challenged the browser");
|
|
48
|
+
assert.ok(requests.some(({ url, authorization }) => url === PAGE_URL && authorization === CREDENTIALS), "the credentials were never sent to the proxy");
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(directory, { recursive: true, force: true });
|
|
51
|
+
proxy.close();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -9,7 +9,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import process from "node:process";
|
|
12
|
-
import { cliDirectory } from "../target.js";
|
|
12
|
+
import { cliDirectory, firefox } from "../target.js";
|
|
13
13
|
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
15
15
|
|
|
@@ -44,7 +44,7 @@ navigator.serviceWorker.register("/sw.js").then(async () => {
|
|
|
44
44
|
});
|
|
45
45
|
</script></body></html>`;
|
|
46
46
|
|
|
47
|
-
test("network options reach the requests made by a service worker", { timeout: 120000 }, async () => {
|
|
47
|
+
test("network options reach the requests made by a service worker", { timeout: 120000, skip: firefox && "Firefox reports the requests of a service worker as intercepted but refuses to continue them" }, async () => {
|
|
48
48
|
const STYLE = "rgb(7,7,7)";
|
|
49
49
|
const requests = [];
|
|
50
50
|
const server = createServer((request, response) => {
|
package/test/fidelity/browser.js
CHANGED
|
@@ -43,8 +43,8 @@ const SETTLE_EXPRESSION = `Promise.race([
|
|
|
43
43
|
export { openBrowser, BAND_HEIGHT };
|
|
44
44
|
|
|
45
45
|
async function openBrowser({ headless = true } = {}) {
|
|
46
|
-
const {
|
|
47
|
-
cdpOptions.apiUrl = LOCALHOST + (await
|
|
46
|
+
const { launchChromium, closeChromium } = await importLibModule("chromium.js");
|
|
47
|
+
cdpOptions.apiUrl = LOCALHOST + (await launchChromium({ headless }));
|
|
48
48
|
// A command with no limit waits for its answer for ever, and that is the default. One that never
|
|
49
49
|
// came back took a whole CI run with it: the suite reported nothing but its own test timeout,
|
|
50
50
|
// the connection stayed wedged, and every check after it in the file was never reached. With a
|
|
@@ -87,7 +87,7 @@ async function openBrowser({ headless = true } = {}) {
|
|
|
87
87
|
|
|
88
88
|
async function close() {
|
|
89
89
|
await CDP.closeTarget(comparisonTarget.targetId).catch(() => { });
|
|
90
|
-
await
|
|
90
|
+
await closeChromium();
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
package/test/target.js
CHANGED
|
@@ -21,6 +21,10 @@ const IGNORED_DIRECTORY_NAMES = ["node_modules", "test", "tmp", "dist", "doc"];
|
|
|
21
21
|
|
|
22
22
|
const repositoryDirectory = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
23
|
const useDevBuild = process.env.SINGLE_FILE_TARGET === "dev";
|
|
24
|
+
// SINGLE_FILE_BROWSER_ENGINE=firefox runs the same suite through the BiDi client, the CLI reads it
|
|
25
|
+
// as the default of --browser-engine; the checks that hold for one engine only say so by name
|
|
26
|
+
const browserEngine = process.env.SINGLE_FILE_BROWSER_ENGINE || "chromium";
|
|
27
|
+
const firefox = browserEngine === "firefox";
|
|
24
28
|
const cliDirectory = useDevBuild ? join(repositoryDirectory, ".dev") : repositoryDirectory;
|
|
25
29
|
|
|
26
30
|
if (useDevBuild) {
|
|
@@ -74,4 +78,4 @@ function importLibModule(name) {
|
|
|
74
78
|
return import(join(cliDirectory, "lib", name));
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
export { cliDirectory, repositoryDirectory, useDevBuild, importLibModule };
|
|
81
|
+
export { cliDirectory, repositoryDirectory, useDevBuild, importLibModule, browserEngine, firefox };
|