single-file-cli 2.8.0 → 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/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const version = "2.8.0";
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 },
@@ -294,6 +297,15 @@ function applySettings(options, settings, explicitOptions = parseArgs(Array.from
294
297
 
295
298
  function getOptions() {
296
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
+ }
297
309
  const unknownOptions = positionals.filter(positional => positional.startsWith("--"));
298
310
  const urls = positionals.filter(positional => !positional.startsWith("--"));
299
311
  if (options.help) {
@@ -316,6 +328,9 @@ function getOptions() {
316
328
  errorMessages.push(`Unexpected arguments: ${urls.slice(2).join(", ")}`);
317
329
  }
318
330
  if (options.createBrowserProfile) {
331
+ if (options.browserEngine == "firefox") {
332
+ errorMessages.push("--create-browser-profile is not supported with --browser-engine firefox");
333
+ }
319
334
  if (options.browserProfile) {
320
335
  errorMessages.push("--create-browser-profile cannot be used with --browser-profile, it already takes the path of the profile directory");
321
336
  }
@@ -326,7 +341,6 @@ function getOptions() {
326
341
  errorMessages.push("--browser-profile cannot be used with --browser-server");
327
342
  }
328
343
  if (!options.crawlLinks) {
329
- const explicitOptions = parseArgs(Array.from(args), false).options;
330
344
  Object.keys(CRAWL_LINKS_DEPENDENT_OPTIONS)
331
345
  .filter(optionKey => explicitOptions[optionKey] !== undefined)
332
346
  .forEach(optionKey => errorMessages.push(`${CRAWL_LINKS_DEPENDENT_OPTIONS[optionKey]} requires --crawl-links`));
@@ -483,6 +497,9 @@ function parseArgs(args, setDefaultValues = true) {
483
497
  result.options.browserServer = result.options.browserRemoteDebuggingUrl;
484
498
  delete result.options.browserRemoteDebuggingUrl;
485
499
  }
500
+ if (result.options.browserEngine !== undefined && !BROWSER_ENGINES.includes(result.options.browserEngine)) {
501
+ invalidOptions.push({ name: "browser-engine", value: result.options.browserEngine });
502
+ }
486
503
  if (result.options.filenameReplacedCharacters) {
487
504
  const filenameReplacedCharacters = result.options.filenameReplacedCharacters;
488
505
  result.options.filenameReplacedCharacters = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "single-file-cli",
3
- "version": "2.8.0",
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
  },
@@ -24,7 +24,8 @@
24
24
  /* global URL */
25
25
 
26
26
  import { Buffer } from "node:buffer";
27
- import * as backend from "./lib/cdp-client.js";
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 && !options.browserServer) {
193
+ if (!options.browserDebug) {
188
194
  await backend.closeBrowser();
189
195
  }
190
196
  return errorCount;
@@ -21,8 +21,8 @@
21
21
  * Source.
22
22
  */
23
23
 
24
- import { initialize } from "./single-file-cli-api.js";
25
- import { closeBrowser, createBrowserProfile, getBrowserOptions } from "./lib/browser.js";
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(getBrowserOptions(options), { profile: profileDirectory, startUrl: options.url }));
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
- assert.ok(scriptAgent[1].includes("Chrome/"), "expected a Chrome user agent, got: " + scriptAgent[1]);
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,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) => {
@@ -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 { launchBrowser, closeBrowser } = await importLibModule("browser.js");
47
- cdpOptions.apiUrl = LOCALHOST + (await launchBrowser({ headless }));
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 closeBrowser();
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 };