single-file-cli 2.3.0 → 2.4.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 +9 -0
- package/build.sh +4 -5
- package/deno.json +1 -1
- package/dev-build.sh +9 -13
- package/lib/browser.js +150 -17
- package/lib/cdp-client.js +133 -57
- package/lib/constants.js +0 -1
- package/lib/deno-polyfill.js +42 -0
- package/lib/single-file-archive.js +10 -6
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/options.js +14 -2
- package/package.json +1 -1
- package/single-file-cli-api.js +7 -1
- package/single-file-launcher.js +14 -2
- package/test/e2e/automation-detection.test.js +103 -0
- package/test/e2e/browser-profile.test.js +67 -0
- package/test/e2e/oopif.test.js +86 -0
- package/test/unit/archive-packager.test.js +1 -2
- package/test/unit/browser-profile.test.js +70 -0
- package/test/unit/cli-usage.test.js +15 -0
- package/lib/archive-packager.js +0 -228
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.
|
|
1
|
+
export const version = "2.4.0";
|
package/options.js
CHANGED
|
@@ -56,6 +56,7 @@ const OPTIONS_INFO = [{
|
|
|
56
56
|
"browser-server": { description: "Server to connect to", type: "string", alias: "browser-remote-debugging-url" },
|
|
57
57
|
"browser-headless": { description: "Run the browser in headless mode", type: "boolean", defaultValue: true },
|
|
58
58
|
"browser-executable-path": { description: "Path to chrome/chromium executable", type: "string" },
|
|
59
|
+
"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" },
|
|
59
60
|
"browser-width": { description: "Width of the browser viewport in pixels", type: "number", defaultValue: 1280 },
|
|
60
61
|
"browser-height": { description: "Height of the browser viewport in pixels", type: "number", defaultValue: 720 },
|
|
61
62
|
"browser-debug": { description: "Enable debug mode", type: "boolean" },
|
|
@@ -203,6 +204,7 @@ const OPTIONS_INFO = [{
|
|
|
203
204
|
|
|
204
205
|
"save-raw-page": { description: "Save the original page without interpreting it into the browser", type: "boolean" },
|
|
205
206
|
"output-json": { description: "Output the result as a JSON string containing the page and network info", type: "boolean" },
|
|
207
|
+
"create-browser-profile": { description: "Path of the browser profile directory to create or update instead of saving a page. The browser is started with a visible window on the URL passed as argument, log in to the website and quit the browser to save the profile, then pass it to --browser-profile when saving pages", type: "string" },
|
|
206
208
|
|
|
207
209
|
}, {
|
|
208
210
|
"help": { description: "Show help", type: "boolean" },
|
|
@@ -303,12 +305,22 @@ function getOptions() {
|
|
|
303
305
|
invalidOptions.forEach(({ name, value }) => errorMessages.push(value === undefined ?
|
|
304
306
|
`Missing value for --${name}` :
|
|
305
307
|
`Invalid value for --${name}: ${JSON.stringify(value)}`));
|
|
306
|
-
if (!urls.length && !options.urlsFile) {
|
|
308
|
+
if (!urls.length && !options.urlsFile && !options.createBrowserProfile) {
|
|
307
309
|
errorMessages.push("The URL or path of the page to save is required");
|
|
308
310
|
}
|
|
309
311
|
if (urls.length > 2) {
|
|
310
312
|
errorMessages.push(`Unexpected arguments: ${urls.slice(2).join(", ")}`);
|
|
311
313
|
}
|
|
314
|
+
if (options.createBrowserProfile) {
|
|
315
|
+
if (options.browserProfile) {
|
|
316
|
+
errorMessages.push("--create-browser-profile cannot be used with --browser-profile, it already takes the path of the profile directory");
|
|
317
|
+
}
|
|
318
|
+
if (options.browserServer) {
|
|
319
|
+
errorMessages.push("--create-browser-profile cannot be used with --browser-server");
|
|
320
|
+
}
|
|
321
|
+
} else if (options.browserProfile && options.browserServer) {
|
|
322
|
+
errorMessages.push("--browser-profile cannot be used with --browser-server");
|
|
323
|
+
}
|
|
312
324
|
if (!options.crawlLinks) {
|
|
313
325
|
const explicitOptions = parseArgs(Array.from(args), false).options;
|
|
314
326
|
Object.keys(CRAWL_LINKS_DEPENDENT_OPTIONS)
|
|
@@ -336,7 +348,7 @@ function printUsage() {
|
|
|
336
348
|
optionType = optionType.replace("[]", "*");
|
|
337
349
|
}
|
|
338
350
|
const optionDescription = optionInfo.description;
|
|
339
|
-
const optionDefaultValue = optionInfo.defaultValue === undefined ? "" : `(default: ${JSON.stringify(optionInfo.defaultValue)})`;
|
|
351
|
+
const optionDefaultValue = optionInfo.defaultValue === undefined ? "" : `(default: ${JSON.stringify(optionInfo.defaultValue).replace(/[\u007f-\u009f]/g, character => "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0"))})`;
|
|
340
352
|
console.log(` --${optionName}: ${optionDescription} <${optionType}> ${optionDefaultValue}`); // eslint-disable-line no-console
|
|
341
353
|
});
|
|
342
354
|
console.log(""); // eslint-disable-line no-console
|
package/package.json
CHANGED
package/single-file-cli-api.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { Buffer } from "node:buffer";
|
|
27
27
|
import * as backend from "./lib/cdp-client.js";
|
|
28
28
|
import { getZipScriptSource } from "./lib/single-file-script.js";
|
|
29
|
-
import { createPagesArchive } from "./lib/archive
|
|
29
|
+
import { createPagesArchive } from "./lib/single-file-archive.js";
|
|
30
30
|
import { Deno, path } from "./lib/deno-polyfill.js";
|
|
31
31
|
|
|
32
32
|
const VALID_URL_TEST = /^(https?|file):\/\//;
|
|
@@ -250,6 +250,12 @@ async function runNextTask() {
|
|
|
250
250
|
task.promise = capturePage(taskOptions);
|
|
251
251
|
const pageData = await task.promise;
|
|
252
252
|
task.status = STATE_PROCESSED;
|
|
253
|
+
if (options.crawlLinks || tasks.length > 1) {
|
|
254
|
+
const processedCount = tasks.filter(task => task.status == STATE_PROCESSED).length;
|
|
255
|
+
const filenameInfo = pageData && pageData.filename && !options.crawlSaveArchive && !options.dumpContent ? " (" + pageData.filename + ")" : "";
|
|
256
|
+
// written to stderr so that stdout stays parseable when using --dump-content
|
|
257
|
+
console.error(`[${processedCount}/${tasks.length}] ${pageData ? "saved" : "failed"} ${task.url}${filenameInfo}`); // eslint-disable-line no-console
|
|
258
|
+
}
|
|
253
259
|
if (pageData) {
|
|
254
260
|
task.filename = pageData.filename;
|
|
255
261
|
task.title = pageData.title;
|
package/single-file-launcher.js
CHANGED
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { initialize } from "./single-file-cli-api.js";
|
|
25
|
-
import { closeBrowser } from "./lib/browser.js";
|
|
25
|
+
import { closeBrowser, createBrowserProfile, getBrowserOptions } from "./lib/browser.js";
|
|
26
26
|
import { Deno } from "./lib/deno-polyfill.js";
|
|
27
27
|
import { getOptions, applySettings, parseUrlsFile } from "./options.js";
|
|
28
28
|
|
|
29
|
-
const { readTextFile, readFile, exit, addSignalListener } = Deno;
|
|
29
|
+
const { readTextFile, readFile, exit, addSignalListener, build } = Deno;
|
|
30
|
+
const QUIT_BROWSER_HINT = build.os == "darwin" ? " (Cmd+Q)" : "";
|
|
30
31
|
|
|
31
32
|
try {
|
|
32
33
|
addSignalListener("SIGTERM", closeBrowserAndExit);
|
|
@@ -49,6 +50,10 @@ async function run() {
|
|
|
49
50
|
const settings = JSON.parse(await readTextFile(options.settingsFile));
|
|
50
51
|
applySettings(options, settings);
|
|
51
52
|
}
|
|
53
|
+
if (options.createBrowserProfile) {
|
|
54
|
+
await saveBrowserProfile(options);
|
|
55
|
+
exit(0);
|
|
56
|
+
}
|
|
52
57
|
if (options.urlsFile) {
|
|
53
58
|
urls = await getUrlsFile(options.urlsFile);
|
|
54
59
|
} else {
|
|
@@ -81,6 +86,13 @@ async function run() {
|
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
async function saveBrowserProfile(options) {
|
|
90
|
+
const profileDirectory = options.createBrowserProfile;
|
|
91
|
+
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
|
|
92
|
+
await createBrowserProfile(Object.assign(getBrowserOptions(options), { profile: profileDirectory, startUrl: options.url }));
|
|
93
|
+
console.error(`Profile saved, use it with --browser-profile ${JSON.stringify(profileDirectory)}.`); // eslint-disable-line no-console
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
function parseCookies(textValue) {
|
|
85
97
|
const httpOnlyRegExp = /^#HttpOnly_(.*)/;
|
|
86
98
|
return textValue.split(/\r\n|\n/)
|
|
@@ -0,0 +1,103 @@
|
|
|
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, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
14
|
+
|
|
15
|
+
test("pages are not captured as controlled by automation", { timeout: 120000 }, async () => {
|
|
16
|
+
const server = createServer((_, response) => response
|
|
17
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
18
|
+
.end("<html><head><title>Automation</title></head><body><p id=result></p>" +
|
|
19
|
+
"<script>document.getElementById(\"result\").textContent = \"webdriver=\" + navigator.webdriver;</script>" +
|
|
20
|
+
"</body></html>"));
|
|
21
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
22
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
23
|
+
try {
|
|
24
|
+
const outputPath = join(directory, "page.html");
|
|
25
|
+
const url = "http://localhost:" + server.address().port + "/";
|
|
26
|
+
const { stderr } = await execFileAsync(process.execPath, [
|
|
27
|
+
"single-file-node.js", url, outputPath
|
|
28
|
+
], { cwd: cliDirectory });
|
|
29
|
+
let content;
|
|
30
|
+
try {
|
|
31
|
+
content = await readFile(outputPath, "utf8");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new Error("missing output file, stderr: " + stderr, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
assert.ok(content.includes("webdriver=false"), "expected navigator.webdriver to be false, got: " + content.match(/webdriver=\w+/));
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(directory, { recursive: true });
|
|
38
|
+
server.close();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("pages are not captured with a headless user agent", { timeout: 120000 }, async () => {
|
|
43
|
+
const server = createServer((request, response) => response
|
|
44
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
45
|
+
.end("<html><head><title>User agent</title></head><body>" +
|
|
46
|
+
"<p id=header-agent>header-agent=" + request.headers["user-agent"] + "</p>" +
|
|
47
|
+
"<p id=script-agent></p>" +
|
|
48
|
+
"<script>document.getElementById(\"script-agent\").textContent = \"script-agent=\" + navigator.userAgent;</script>" +
|
|
49
|
+
"</body></html>"));
|
|
50
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
51
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
52
|
+
try {
|
|
53
|
+
const outputPath = join(directory, "page.html");
|
|
54
|
+
const url = "http://localhost:" + server.address().port + "/";
|
|
55
|
+
const { stderr } = await execFileAsync(process.execPath, [
|
|
56
|
+
"single-file-node.js", url, outputPath
|
|
57
|
+
], { cwd: cliDirectory });
|
|
58
|
+
let content;
|
|
59
|
+
try {
|
|
60
|
+
content = await readFile(outputPath, "utf8");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new Error("missing output file, stderr: " + stderr, { cause: error });
|
|
63
|
+
}
|
|
64
|
+
const headerAgent = content.match(/header-agent=([^<]*)/);
|
|
65
|
+
const scriptAgent = content.match(/script-agent=([^<]*)/);
|
|
66
|
+
assert.ok(headerAgent, "missing the user agent sent to the server");
|
|
67
|
+
assert.ok(scriptAgent, "missing the user agent read in the page");
|
|
68
|
+
assert.ok(!headerAgent[1].includes("Headless"), "unexpected headless token in the user agent sent to the server: " + headerAgent[1]);
|
|
69
|
+
assert.ok(!scriptAgent[1].includes("Headless"), "unexpected headless token in the user agent read in the page: " + scriptAgent[1]);
|
|
70
|
+
assert.ok(scriptAgent[1].includes("Chrome/"), "expected a Chrome user agent, got: " + scriptAgent[1]);
|
|
71
|
+
} finally {
|
|
72
|
+
await rm(directory, { recursive: true });
|
|
73
|
+
server.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the user agent option overrides the browser user agent", { timeout: 120000 }, async () => {
|
|
78
|
+
const userAgent = "Mozilla/5.0 (SingleFile test) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
|
|
79
|
+
const server = createServer((request, response) => response
|
|
80
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
81
|
+
.end("<html><head><title>User agent</title></head><body>" +
|
|
82
|
+
"<p id=header-agent>header-agent=" + request.headers["user-agent"] + "</p>" +
|
|
83
|
+
"</body></html>"));
|
|
84
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
85
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
86
|
+
try {
|
|
87
|
+
const outputPath = join(directory, "page.html");
|
|
88
|
+
const url = "http://localhost:" + server.address().port + "/";
|
|
89
|
+
const { stderr } = await execFileAsync(process.execPath, [
|
|
90
|
+
"single-file-node.js", url, outputPath, "--user-agent=" + userAgent
|
|
91
|
+
], { cwd: cliDirectory });
|
|
92
|
+
let content;
|
|
93
|
+
try {
|
|
94
|
+
content = await readFile(outputPath, "utf8");
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error("missing output file, stderr: " + stderr, { cause: error });
|
|
97
|
+
}
|
|
98
|
+
assert.ok(content.includes("header-agent=" + userAgent), "expected the user agent option to be sent, got: " + content.match(/header-agent=[^<]*/));
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(directory, { recursive: true });
|
|
101
|
+
server.close();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
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, mkdir, readdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
14
|
+
|
|
15
|
+
test("a browser profile is copied and left unmodified by a capture", { timeout: 120000 }, async () => {
|
|
16
|
+
const server = createServer((_, response) => response
|
|
17
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
18
|
+
.end("<html><head><title>Profile</title></head><body>content</body></html>"));
|
|
19
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
20
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
21
|
+
try {
|
|
22
|
+
const profilePath = join(directory, "profile");
|
|
23
|
+
await mkdir(join(profilePath, "Default"), { recursive: true });
|
|
24
|
+
await writeFile(join(profilePath, "Default", "Preferences"), "{}");
|
|
25
|
+
const outputPath = join(directory, "page.html");
|
|
26
|
+
const url = "http://localhost:" + server.address().port + "/";
|
|
27
|
+
const { stderr } = await execFileAsync(process.execPath, [
|
|
28
|
+
"single-file-node.js", url, outputPath,
|
|
29
|
+
"--browser-profile", profilePath
|
|
30
|
+
], { cwd: cliDirectory });
|
|
31
|
+
let content;
|
|
32
|
+
try {
|
|
33
|
+
content = await readFile(outputPath, "utf8");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new Error("missing output file, stderr: " + stderr, { cause: error });
|
|
36
|
+
}
|
|
37
|
+
assert.ok(content.includes("<title>Profile</title>"));
|
|
38
|
+
assert.deepEqual(await readdir(profilePath), ["Default"]);
|
|
39
|
+
assert.deepEqual(await readdir(join(profilePath, "Default")), ["Preferences"]);
|
|
40
|
+
assert.equal(await readFile(join(profilePath, "Default", "Preferences"), "utf8"), "{}");
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(directory, { recursive: true });
|
|
43
|
+
server.close();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a missing browser profile is reported with its path", { timeout: 120000 }, async () => {
|
|
48
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
49
|
+
try {
|
|
50
|
+
const profilePath = join(directory, "missing");
|
|
51
|
+
let exitCode = 0;
|
|
52
|
+
let stderr = "";
|
|
53
|
+
try {
|
|
54
|
+
await execFileAsync(process.execPath, [
|
|
55
|
+
"single-file-node.js", "https://example.com", join(directory, "page.html"),
|
|
56
|
+
"--browser-profile", profilePath
|
|
57
|
+
], { cwd: cliDirectory });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
exitCode = error.code;
|
|
60
|
+
stderr = error.stderr;
|
|
61
|
+
}
|
|
62
|
+
assert.notEqual(exitCode, 0);
|
|
63
|
+
assert.ok(stderr.includes(`The browser profile directory was not found at ${JSON.stringify(profilePath)}`));
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(directory, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
const FRAME_MARKER = "OUT_OF_PROCESS_FRAME_CONTENT";
|
|
17
|
+
|
|
18
|
+
test("blocked URL patterns and extra HTTP headers apply in cross-origin frames", { timeout: 120000 }, async () => {
|
|
19
|
+
const BLOCKED_STYLE = "rgb(123,45,67)";
|
|
20
|
+
const GATED_STYLE = "rgb(89,89,89)";
|
|
21
|
+
const frameServer = createServer((request, response) => {
|
|
22
|
+
const { pathname } = new URL(request.url, "http://127.0.0.1");
|
|
23
|
+
if (pathname === "/tracker.css") {
|
|
24
|
+
response.writeHead(200, { "content-type": "text/css" }).end("h2 { color: " + BLOCKED_STYLE + "; }");
|
|
25
|
+
} else if (pathname === "/gated.css") {
|
|
26
|
+
if (request.headers["x-test-header"] === "yes") {
|
|
27
|
+
response.writeHead(200, { "content-type": "text/css" }).end("h3 { color: " + GATED_STYLE + "; }");
|
|
28
|
+
} else {
|
|
29
|
+
response.writeHead(404).end();
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
response.writeHead(200, { "content-type": "text/html" }).end(
|
|
33
|
+
"<html><head><link rel=\"stylesheet\" href=\"/tracker.css\"><link rel=\"stylesheet\" href=\"/gated.css\"></head>" +
|
|
34
|
+
"<body><h2>blocked</h2><h3>gated</h3></body></html>");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await new Promise(resolve => frameServer.listen(0, "127.0.0.1", resolve));
|
|
38
|
+
const frameUrl = "http://127.0.0.1:" + frameServer.address().port + "/frame.html";
|
|
39
|
+
const server = createServer((request, response) => {
|
|
40
|
+
response.writeHead(200, { "content-type": "text/html" }).end("<html><body><iframe src=\"" + frameUrl + "\"></iframe></body></html>");
|
|
41
|
+
});
|
|
42
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
43
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
44
|
+
try {
|
|
45
|
+
const outputPath = join(directory, "out.html");
|
|
46
|
+
const url = "http://localhost:" + server.address().port + "/top.html";
|
|
47
|
+
await execFileAsync(process.execPath, [
|
|
48
|
+
"single-file-node.js", url, outputPath,
|
|
49
|
+
"--blocked-URL-pattern", "tracker",
|
|
50
|
+
"--http-header", "x-test-header=yes"
|
|
51
|
+
], { cwd: cliDirectory });
|
|
52
|
+
const content = await readFile(outputPath, "utf8");
|
|
53
|
+
assert.ok(!content.includes(BLOCKED_STYLE), "a blocked stylesheet leaked into the frame");
|
|
54
|
+
assert.ok(content.includes(GATED_STYLE), "the extra HTTP header was not sent for a frame resource");
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(directory, { recursive: true });
|
|
57
|
+
server.close();
|
|
58
|
+
frameServer.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("cross-origin frames are captured in the saved page", { timeout: 120000 }, async () => {
|
|
63
|
+
// localhost and 127.0.0.1 are different sites, so site isolation renders
|
|
64
|
+
// the frame out of process, in a separate CDP target
|
|
65
|
+
const frameServer = createServer((request, response) => {
|
|
66
|
+
response.writeHead(200, { "content-type": "text/html" }).end("<html><body><p>" + FRAME_MARKER + "</p></body></html>");
|
|
67
|
+
});
|
|
68
|
+
await new Promise(resolve => frameServer.listen(0, "127.0.0.1", resolve));
|
|
69
|
+
const frameUrl = "http://127.0.0.1:" + frameServer.address().port + "/frame.html";
|
|
70
|
+
const server = createServer((request, response) => {
|
|
71
|
+
response.writeHead(200, { "content-type": "text/html" }).end("<html><body><iframe src=\"" + frameUrl + "\"></iframe></body></html>");
|
|
72
|
+
});
|
|
73
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
74
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
75
|
+
try {
|
|
76
|
+
const outputPath = join(directory, "out.html");
|
|
77
|
+
const url = "http://localhost:" + server.address().port + "/top.html";
|
|
78
|
+
await execFileAsync(process.execPath, ["single-file-node.js", url, outputPath], { cwd: cliDirectory });
|
|
79
|
+
const content = await readFile(outputPath, "utf8");
|
|
80
|
+
assert.ok(content.includes(FRAME_MARKER), "the out-of-process frame was saved empty");
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(directory, { recursive: true });
|
|
83
|
+
server.close();
|
|
84
|
+
frameServer.close();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { createPagesArchive } from "../../lib/archive
|
|
4
|
-
import { configure, ZipWriter, ZipReader, Uint8ArrayReader, Uint8ArrayWriter, TextReader, TextWriter } from "../../lib/single-file-archive.js";
|
|
3
|
+
import { createPagesArchive, configure, ZipWriter, ZipReader, Uint8ArrayReader, Uint8ArrayWriter, TextReader, TextWriter } from "../../lib/single-file-archive.js";
|
|
5
4
|
|
|
6
5
|
configure({ useWebWorkers: false });
|
|
7
6
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, mkdir, readdir, readFile, symlink, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { copyProfile } from "../../lib/browser.js";
|
|
7
|
+
|
|
8
|
+
async function createProfileDirectory() {
|
|
9
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
10
|
+
const sourcePath = join(directory, "profile");
|
|
11
|
+
await mkdir(join(sourcePath, "Default", "Cache"), { recursive: true });
|
|
12
|
+
await mkdir(join(sourcePath, "Default", "Local Storage"), { recursive: true });
|
|
13
|
+
await mkdir(join(sourcePath, "GPUCache"), { recursive: true });
|
|
14
|
+
await writeFile(join(sourcePath, "Local State"), "state");
|
|
15
|
+
await writeFile(join(sourcePath, "Default", "Cookies"), "cookies");
|
|
16
|
+
await writeFile(join(sourcePath, "Default", "Cache", "data_0"), "cached");
|
|
17
|
+
await writeFile(join(sourcePath, "Default", "Local Storage", "leveldb"), "storage");
|
|
18
|
+
await writeFile(join(sourcePath, "GPUCache", "index"), "cached");
|
|
19
|
+
await symlink("hostname-1234", join(sourcePath, "SingletonLock"));
|
|
20
|
+
return { directory, sourcePath, destinationPath: join(directory, "copy") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("copying a profile keeps the session data", async () => {
|
|
24
|
+
const { directory, sourcePath, destinationPath } = await createProfileDirectory();
|
|
25
|
+
try {
|
|
26
|
+
await mkdir(destinationPath);
|
|
27
|
+
await copyProfile(sourcePath, destinationPath);
|
|
28
|
+
assert.equal(await readFile(join(destinationPath, "Local State"), "utf8"), "state");
|
|
29
|
+
assert.equal(await readFile(join(destinationPath, "Default", "Cookies"), "utf8"), "cookies");
|
|
30
|
+
assert.equal(await readFile(join(destinationPath, "Default", "Local Storage", "leveldb"), "utf8"), "storage");
|
|
31
|
+
} finally {
|
|
32
|
+
await rm(directory, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("copying a profile skips caches and lock files", async () => {
|
|
37
|
+
const { directory, sourcePath, destinationPath } = await createProfileDirectory();
|
|
38
|
+
try {
|
|
39
|
+
await mkdir(destinationPath);
|
|
40
|
+
await copyProfile(sourcePath, destinationPath);
|
|
41
|
+
assert.deepEqual((await readdir(destinationPath)).sort(), ["Default", "Local State"]);
|
|
42
|
+
assert.deepEqual((await readdir(join(destinationPath, "Default"))).sort(), ["Cookies", "Local Storage"]);
|
|
43
|
+
} finally {
|
|
44
|
+
await rm(directory, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("copying a profile leaves the source directory unmodified", async () => {
|
|
49
|
+
const { directory, sourcePath, destinationPath } = await createProfileDirectory();
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(destinationPath);
|
|
52
|
+
await copyProfile(sourcePath, destinationPath);
|
|
53
|
+
assert.deepEqual((await readdir(sourcePath)).sort(), ["Default", "GPUCache", "Local State", "SingletonLock"]);
|
|
54
|
+
assert.deepEqual((await readdir(join(sourcePath, "Default"))).sort(), ["Cache", "Cookies", "Local Storage"]);
|
|
55
|
+
} finally {
|
|
56
|
+
await rm(directory, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("copying a missing profile is reported with its path", async () => {
|
|
61
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
62
|
+
try {
|
|
63
|
+
const missingPath = join(directory, "missing");
|
|
64
|
+
await assert.rejects(
|
|
65
|
+
() => copyProfile(missingPath, directory),
|
|
66
|
+
error => error.message == `The browser profile directory was not found at ${JSON.stringify(missingPath)}`);
|
|
67
|
+
} finally {
|
|
68
|
+
await rm(directory, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
@@ -49,6 +49,21 @@ test("a wrong browser executable path is reported with the path", async () => {
|
|
|
49
49
|
assert.ok(stderr.includes("The browser executable was not found at \"/nonexistent/chrome\""));
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
test("creating a browser profile does not require a url", async () => {
|
|
53
|
+
const { code, stderr } = await runCli(["--create-browser-profile", "/path/to/profile", "--browser-executable-path", "/nonexistent/chrome"]);
|
|
54
|
+
assert.notEqual(code, 0);
|
|
55
|
+
assert.equal(stderr.includes("The URL or path of the page to save is required"), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("conflicting browser profile options are reported as errors", async () => {
|
|
59
|
+
const { code, stderr } = await runCli(["--create-browser-profile", "/path/to/profile", "--browser-profile", "/path/to/other"]);
|
|
60
|
+
assert.equal(code, 1);
|
|
61
|
+
assert.ok(stderr.includes("--create-browser-profile cannot be used with --browser-profile"));
|
|
62
|
+
const remoteResult = await runCli(["https://example.com", "--browser-profile", "/path/to/profile", "--browser-server", "http://localhost:9222"]);
|
|
63
|
+
assert.equal(remoteResult.code, 1);
|
|
64
|
+
assert.ok(remoteResult.stderr.includes("--browser-profile cannot be used with --browser-server"));
|
|
65
|
+
});
|
|
66
|
+
|
|
52
67
|
test("unexpected extra arguments are reported as errors", async () => {
|
|
53
68
|
const { code, stderr } = await runCli(["https://example.com", "out.html", "extra.html"]);
|
|
54
69
|
assert.equal(code, 1);
|