single-file-cli 2.2.2 → 2.3.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/Dockerfile +1 -0
- package/README.MD +6 -2
- package/build.sh +1 -1
- package/deno.json +1 -1
- package/lib/archive-packager.js +3 -0
- package/lib/cdp-client-util.js +4 -1
- package/lib/cdp-client.js +144 -58
- package/lib/single-file-archive.js +6 -6
- package/lib/single-file-bundle.js +1 -1
- package/lib/version.js +1 -1
- package/options.js +9 -4
- package/package.json +1 -1
- package/single-file-cli-api.js +17 -2
- package/single-file-launcher.js +4 -1
- package/test/e2e/errors-file.test.js +11 -5
- package/test/e2e/oopif.test.js +86 -0
- package/test/e2e/urls-file.test.js +31 -0
- package/test/unit/archive-packager.test.js +15 -2
- package/test/unit/options.test.js +3 -0
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.
|
|
1
|
+
export const version = "2.3.1";
|
package/options.js
CHANGED
|
@@ -53,7 +53,7 @@ const CATEGORIES = [
|
|
|
53
53
|
];
|
|
54
54
|
|
|
55
55
|
const OPTIONS_INFO = [{
|
|
56
|
-
"browser-server": { description: "Server to connect to", type: "string" },
|
|
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
59
|
"browser-width": { description: "Width of the browser viewport in pixels", type: "number", defaultValue: 1280 },
|
|
@@ -64,7 +64,7 @@ const OPTIONS_INFO = [{
|
|
|
64
64
|
"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" },
|
|
65
65
|
"browser-start-minimized": { description: "Minimize the browser", type: "boolean" },
|
|
66
66
|
"browser-ignore-insecure-certs": { description: "Ignore HTTPs errors", type: "boolean" },
|
|
67
|
-
"browser-
|
|
67
|
+
"browser-bypass-CSP": { description: "Bypass the Content Security Policy of the page, needed to save pages enforcing Trusted Types with browsers based on Chromium 150 or older", type: "boolean", defaultValue: false }
|
|
68
68
|
}, {
|
|
69
69
|
"browser-load-max-time": { description: "Maximum delay of time to wait for loading the page in ms", type: "number", defaultValue: 60000 },
|
|
70
70
|
"browser-capture-max-time": { description: "Maximum delay of time to wait for capturing the page in ms", type: "number", defaultValue: 60000 },
|
|
@@ -164,6 +164,7 @@ const OPTIONS_INFO = [{
|
|
|
164
164
|
"crawl-save-archive": { description: "Save all the crawled pages into a single (self-extracting) ZIP file, requires --compress-content", type: "boolean" },
|
|
165
165
|
"crawl-save-archive-dedup": { description: "Deduplicate identical resources shared between pages when using --crawl-save-archive", type: "boolean" },
|
|
166
166
|
"crawl-save-archive-mark-unarchived-links": { description: "Mark links to pages not saved in the archive when using --crawl-save-archive", type: "boolean" },
|
|
167
|
+
"crawl-save-archive-page-transitions": { description: "Page transitions when navigating in the archive saved with --crawl-save-archive. The possible values are \"auto\" (default, i.e. transitions run when pages opt in via CSS), \"fade\" and \"none\"", type: "string", defaultValue: "auto" },
|
|
167
168
|
"crawl-save-archive-toc": { description: "Save a table of contents page into the archive when using --crawl-save-archive", type: "boolean" },
|
|
168
169
|
}, {
|
|
169
170
|
"browser-script": { description: "Path of a script executed in the page (and all the frames) before it is loaded", type: "string[]" },
|
|
@@ -335,7 +336,7 @@ function printUsage() {
|
|
|
335
336
|
optionType = optionType.replace("[]", "*");
|
|
336
337
|
}
|
|
337
338
|
const optionDescription = optionInfo.description;
|
|
338
|
-
const optionDefaultValue = optionInfo.defaultValue === undefined ? "" : `(default: ${JSON.stringify(optionInfo.defaultValue)})`;
|
|
339
|
+
const optionDefaultValue = optionInfo.defaultValue === undefined ? "" : `(default: ${JSON.stringify(optionInfo.defaultValue).replace(/[\u007f-\u009f]/g, character => "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0"))})`;
|
|
339
340
|
console.log(` --${optionName}: ${optionDescription} <${optionType}> ${optionDefaultValue}`); // eslint-disable-line no-console
|
|
340
341
|
});
|
|
341
342
|
console.log(""); // eslint-disable-line no-console
|
|
@@ -454,6 +455,10 @@ function parseArgs(args, setDefaultValues = true) {
|
|
|
454
455
|
result.options.errorsTracesDisabled = result.options.errorTracesDisabled;
|
|
455
456
|
delete result.options.errorTracesDisabled;
|
|
456
457
|
}
|
|
458
|
+
if (result.options.browserRemoteDebuggingUrl !== undefined) {
|
|
459
|
+
result.options.browserServer = result.options.browserRemoteDebuggingUrl;
|
|
460
|
+
delete result.options.browserRemoteDebuggingUrl;
|
|
461
|
+
}
|
|
457
462
|
if (result.options.filenameReplacedCharacters) {
|
|
458
463
|
const filenameReplacedCharacters = result.options.filenameReplacedCharacters;
|
|
459
464
|
result.options.filenameReplacedCharacters = [];
|
|
@@ -556,7 +561,7 @@ function getOptionInfo(optionName) {
|
|
|
556
561
|
let result;
|
|
557
562
|
OPTIONS_INFO.forEach(categoryOptions => {
|
|
558
563
|
Object.keys(categoryOptions).forEach(keyName => {
|
|
559
|
-
if (keyName.toLowerCase() == optionName.toLowerCase() || categoryOptions[keyName].alias == optionName.toLowerCase()) {
|
|
564
|
+
if (keyName.toLowerCase() == optionName.toLowerCase() || (categoryOptions[keyName].alias && categoryOptions[keyName].alias.toLowerCase() == optionName.toLowerCase())) {
|
|
560
565
|
result = { name: keyName, info: categoryOptions[keyName] };
|
|
561
566
|
}
|
|
562
567
|
});
|
package/package.json
CHANGED
package/single-file-cli-api.js
CHANGED
|
@@ -61,7 +61,7 @@ const STATE_PROCESSING = "processing";
|
|
|
61
61
|
const STATE_PROCESSED = "processed";
|
|
62
62
|
|
|
63
63
|
const { readTextFile, writeTextFile, readFile, writeFile, stdout, mkdir, makeTempDir, remove, stat, errors } = Deno;
|
|
64
|
-
let tasks = [], maxParallelWorkers, sessionFilename, archiveTempDirectory;
|
|
64
|
+
let tasks = [], maxParallelWorkers, sessionFilename, archiveTempDirectory, errorCount = 0;
|
|
65
65
|
|
|
66
66
|
export { initialize };
|
|
67
67
|
|
|
@@ -79,6 +79,12 @@ async function initialize(options) {
|
|
|
79
79
|
if (options.crawlSaveArchiveToc && !options.crawlSaveArchive) {
|
|
80
80
|
throw new Error("--crawl-save-archive-toc requires --crawl-save-archive");
|
|
81
81
|
}
|
|
82
|
+
if (options.crawlSaveArchivePageTransitions !== undefined && !["auto", "fade", "none"].includes(options.crawlSaveArchivePageTransitions)) {
|
|
83
|
+
throw new Error("--crawl-save-archive-page-transitions must be \"auto\", \"fade\" or \"none\"");
|
|
84
|
+
}
|
|
85
|
+
if (options.crawlSaveArchivePageTransitions !== undefined && options.crawlSaveArchivePageTransitions != "auto" && !options.crawlSaveArchive) {
|
|
86
|
+
throw new Error("--crawl-save-archive-page-transitions requires --crawl-save-archive");
|
|
87
|
+
}
|
|
82
88
|
if (options.crawlSaveArchive) {
|
|
83
89
|
if (!options.compressContent) {
|
|
84
90
|
throw new Error("--crawl-save-archive requires --compress-content");
|
|
@@ -174,8 +180,9 @@ async function finish(options) {
|
|
|
174
180
|
}
|
|
175
181
|
}
|
|
176
182
|
if (!options.browserDebug && !options.browserServer) {
|
|
177
|
-
|
|
183
|
+
await backend.closeBrowser();
|
|
178
184
|
}
|
|
185
|
+
return errorCount;
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
async function savePagesArchive(options) {
|
|
@@ -192,6 +199,7 @@ async function savePagesArchive(options) {
|
|
|
192
199
|
dedupPages: options.crawlSaveArchiveDedup,
|
|
193
200
|
markUnarchivedLinks: options.crawlSaveArchiveMarkUnarchivedLinks,
|
|
194
201
|
tocPage: options.crawlSaveArchiveToc,
|
|
202
|
+
pageTransitions: options.crawlSaveArchivePageTransitions,
|
|
195
203
|
selfExtractingArchive: options.selfExtractingArchive,
|
|
196
204
|
extractDataFromPage: options.extractDataFromPage,
|
|
197
205
|
preventAppendedData: options.preventAppendedData,
|
|
@@ -242,6 +250,12 @@ async function runNextTask() {
|
|
|
242
250
|
task.promise = capturePage(taskOptions);
|
|
243
251
|
const pageData = await task.promise;
|
|
244
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 ? " (" + 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
|
+
}
|
|
245
259
|
if (pageData) {
|
|
246
260
|
task.filename = pageData.filename;
|
|
247
261
|
task.title = pageData.title;
|
|
@@ -391,6 +405,7 @@ async function capturePage(options) {
|
|
|
391
405
|
}
|
|
392
406
|
return pageData;
|
|
393
407
|
} catch (error) {
|
|
408
|
+
errorCount++;
|
|
394
409
|
const date = new Date();
|
|
395
410
|
let message = `[${date.toISOString()}] URL: ${options.url} Error: ${error.message || error}`;
|
|
396
411
|
if (!options.errorsTracesDisabled) {
|
package/single-file-launcher.js
CHANGED
|
@@ -71,7 +71,10 @@ async function run() {
|
|
|
71
71
|
options.retrieveLinks = true;
|
|
72
72
|
const singlefile = await initialize(options);
|
|
73
73
|
await singlefile.capture(urls);
|
|
74
|
-
await singlefile.finish();
|
|
74
|
+
const errorCount = await singlefile.finish();
|
|
75
|
+
if (errorCount) {
|
|
76
|
+
exit(1);
|
|
77
|
+
}
|
|
75
78
|
} catch (error) {
|
|
76
79
|
console.error(error.message || error); // eslint-disable-line no-console
|
|
77
80
|
await closeBrowserAndExit(-1);
|
|
@@ -11,15 +11,21 @@ import process from "node:process";
|
|
|
11
11
|
const execFileAsync = promisify(execFile);
|
|
12
12
|
const cliDirectory = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
13
13
|
|
|
14
|
-
test("errors file lines include the error message", { timeout: 120000 }, async () => {
|
|
14
|
+
test("errors file lines include the error message and the exit code is nonzero", { timeout: 120000 }, async () => {
|
|
15
15
|
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
16
16
|
try {
|
|
17
17
|
const errorsPath = join(directory, "errors.txt");
|
|
18
18
|
const url = "http://localhost:1/";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
let exitCode = 0;
|
|
20
|
+
try {
|
|
21
|
+
await execFileAsync(process.execPath, [
|
|
22
|
+
"single-file-node.js", url, join(directory, "out.html"),
|
|
23
|
+
"--errors-file", errorsPath
|
|
24
|
+
], { cwd: cliDirectory });
|
|
25
|
+
} catch (error) {
|
|
26
|
+
exitCode = error.code;
|
|
27
|
+
}
|
|
28
|
+
assert.equal(exitCode, 1);
|
|
23
29
|
const content = await readFile(errorsPath, "utf8");
|
|
24
30
|
assert.ok(content.includes("URL: " + url));
|
|
25
31
|
const errorMessage = content.match(/Error: (.*)/);
|
|
@@ -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
|
+
});
|
|
@@ -35,3 +35,34 @@ test("duplicate urls in a urls file are captured once", { timeout: 120000 }, asy
|
|
|
35
35
|
server.close();
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
test("a failed capture in a urls file batch sets a nonzero exit code without aborting the batch", { timeout: 120000 }, async () => {
|
|
40
|
+
const server = createServer((_, response) => response
|
|
41
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
42
|
+
.end("<html><head><title>Good Page</title></head><body>content</body></html>"));
|
|
43
|
+
await new Promise(resolve => server.listen(0, "localhost", resolve));
|
|
44
|
+
const directory = await mkdtemp(join(tmpdir(), "single-file-test-"));
|
|
45
|
+
try {
|
|
46
|
+
const goodUrl = "http://localhost:" + server.address().port + "/";
|
|
47
|
+
const urlsFilePath = join(directory, "urls.txt");
|
|
48
|
+
await writeFile(urlsFilePath, "http://localhost:1/\n" + goodUrl + "\n");
|
|
49
|
+
let exitCode = 0, stderr = "";
|
|
50
|
+
try {
|
|
51
|
+
({ stderr } = await execFileAsync(process.execPath, [
|
|
52
|
+
"single-file-node.js",
|
|
53
|
+
"--urls-file", urlsFilePath,
|
|
54
|
+
"--output-directory", directory,
|
|
55
|
+
"--filename-template", "{page-title}.html"
|
|
56
|
+
], { cwd: cliDirectory }));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
exitCode = error.code;
|
|
59
|
+
stderr = error.stderr;
|
|
60
|
+
}
|
|
61
|
+
assert.equal(exitCode, 1);
|
|
62
|
+
const files = (await readdir(directory)).filter(file => file.endsWith(".html"));
|
|
63
|
+
assert.deepEqual(files, ["Good Page.html"], "stderr: " + stderr);
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(directory, { recursive: true });
|
|
66
|
+
server.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
@@ -31,13 +31,26 @@ test("the table of contents groups by origin when the crawl crossed hosts", asyn
|
|
|
31
31
|
assert.ok(toc.includes("<summary>https://other.example</summary>"));
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
async
|
|
34
|
+
test("the page transitions setting is stored in the manifest except the default", async () => {
|
|
35
|
+
for (const pageTransitions of [undefined, "auto"]) {
|
|
36
|
+
const archive = await createArchive([{ url: "https://example.com/", title: "Home" }], { pageTransitions });
|
|
37
|
+
const manifest = JSON.parse(await readEntry(archive, "sfz-pages.json"));
|
|
38
|
+
assert.equal(manifest.pageTransitions, undefined, "the default is not stored");
|
|
39
|
+
}
|
|
40
|
+
for (const pageTransitions of ["fade", "none"]) {
|
|
41
|
+
const archive = await createArchive([{ url: "https://example.com/", title: "Home" }], { pageTransitions });
|
|
42
|
+
const manifest = JSON.parse(await readEntry(archive, "sfz-pages.json"));
|
|
43
|
+
assert.equal(manifest.pageTransitions, pageTransitions);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function createArchive(pages, options = {}) {
|
|
35
48
|
return createPagesArchive(pages.map(page => ({
|
|
36
49
|
url: page.url,
|
|
37
50
|
originalUrls: [page.url],
|
|
38
51
|
title: page.title,
|
|
39
52
|
getData: () => createPageData(page)
|
|
40
|
-
})), { tocPage: true, selfExtractingArchive: false });
|
|
53
|
+
})), Object.assign({ tocPage: true, selfExtractingArchive: false }, options));
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
async function createPageData(page) {
|
|
@@ -27,6 +27,9 @@ test("aliases map to the canonical option", () => {
|
|
|
27
27
|
assert.equal(parse(["--error-traces-disabled", "false"]).errorsTracesDisabled, false);
|
|
28
28
|
assert.equal(parse(["--errors-traces-disabled", "false"]).errorsTracesDisabled, false);
|
|
29
29
|
assert.equal(parse([]).errorsTracesDisabled, true);
|
|
30
|
+
assert.equal(parse(["--browser-remote-debugging-URL", "http://localhost:9222"]).browserServer, "http://localhost:9222");
|
|
31
|
+
assert.equal(parse(["--browser-server", "http://localhost:9222"]).browserServer, "http://localhost:9222");
|
|
32
|
+
assert.equal("browserRemoteDebuggingUrl" in parse(["--browser-remote-debugging-url", "http://localhost:9222"]), false);
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
test("browser arguments are merged", () => {
|