@itmetsam/samurai-framework 0.8.0 → 0.10.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 -9
- package/dist/browser/browser-finder.js +2 -2
- package/dist/browser/browser.d.ts +11 -15
- package/dist/browser/browser.js +79 -58
- package/dist/browser/chrome-launch.d.ts +69 -0
- package/dist/browser/chrome-launch.js +170 -0
- package/dist/browser/download-folders.d.ts +27 -0
- package/dist/browser/download-folders.js +97 -0
- package/dist/browser/page-logs.d.ts +2 -0
- package/dist/browser/page-logs.js +25 -5
- package/dist/browser/page.d.ts +18 -0
- package/dist/browser/page.js +99 -2
- package/dist/browser/screenshot.js +16 -2
- package/dist/browser/session-capabilities.d.ts +12 -0
- package/dist/browser/session-capabilities.js +8 -0
- package/dist/browsers/index.d.ts +2 -2
- package/dist/browsers/index.js +2 -2
- package/dist/browsers/overrides.d.ts +2 -0
- package/dist/browsers/overrides.js +2 -0
- package/dist/browsers/pins.d.ts +1 -1
- package/dist/browsers/pins.js +2 -2
- package/dist/browsers/resolve.d.ts +2 -0
- package/dist/browsers/resolve.js +14 -2
- package/dist/browsers/store.d.ts +2 -0
- package/dist/browsers/store.js +8 -0
- package/dist/cli/args.d.ts +7 -2
- package/dist/cli/args.js +7 -1
- package/dist/cli/console-reporter.d.ts +4 -0
- package/dist/cli/console-reporter.js +13 -6
- package/dist/cli/init.js +1 -1
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +7 -0
- package/dist/config/config.js +5 -1
- package/dist/flows/run/handlers/support.d.ts +4 -1
- package/dist/flows/run/handlers/support.js +1 -1
- package/dist/flows/run/project.js +9 -1
- package/dist/flows/run/run.d.ts +5 -0
- package/dist/flows/run/run.js +7 -1
- package/dist/locator/locator.js +21 -2
- package/dist/network/data-collector.d.ts +1 -0
- package/dist/network/data-collector.js +23 -5
- package/dist/network/network-tracker.d.ts +8 -1
- package/dist/network/network-tracker.js +42 -3
- package/dist/network/router.d.ts +5 -0
- package/dist/network/router.js +33 -11
- package/dist/recorder/session.d.ts +3 -0
- package/dist/recorder/session.js +7 -3
- package/dist/recorder/toolbar/controller.d.ts +6 -5
- package/dist/recorder/toolbar/controller.js +39 -7
- package/dist/runner/browsers.d.ts +22 -0
- package/dist/runner/browsers.js +44 -0
- package/dist/runner/groups.d.ts +5 -1
- package/dist/runner/groups.js +9 -2
- package/dist/runner/reporter.d.ts +10 -2
- package/dist/runner/reporter.js +30 -10
- package/dist/runner/run.d.ts +1 -0
- package/dist/runner/run.js +1 -0
- package/dist/runner/test-runner.d.ts +9 -1
- package/dist/runner/test-runner.js +46 -19
- package/dist/script/preload.js +65 -0
- package/dist/transport/bidi-connection.d.ts +19 -0
- package/dist/transport/bidi-connection.js +34 -1
- package/dist/types/browser.d.ts +1 -1
- package/dist/types/config.d.ts +5 -3
- package/dist/types/test.d.ts +8 -0
- package/package.json +3 -2
|
@@ -2,16 +2,19 @@ const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
|
|
|
2
2
|
/** Prints a run for a person: one line per test as it ends, then every failure in full and a count */
|
|
3
3
|
export class ConsoleReporter {
|
|
4
4
|
constructor({ write, color }) {
|
|
5
|
+
/** Whether the run has more than one browser: only then is each test labelled with its browser */
|
|
6
|
+
this.labelled = false;
|
|
5
7
|
this.write = write;
|
|
6
8
|
this.paint = (code, text) => color ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
7
9
|
}
|
|
8
10
|
handle(event) {
|
|
9
11
|
switch (event.type) {
|
|
10
12
|
case "run-start":
|
|
11
|
-
this.
|
|
13
|
+
this.labelled = event.browsers.length > 1;
|
|
14
|
+
this.write(`Running ${event.total} ${event.total === 1 ? "test" : "tests"} against ${event.environment}${this.labelled ? ` in ${event.browsers.join(", ")}` : ""}\n`);
|
|
12
15
|
return;
|
|
13
16
|
case "test-end":
|
|
14
|
-
this.write(` ${this.line(event.name, event.result)}\n`);
|
|
17
|
+
this.write(` ${this.line(event.name, event.result, event.browser)}\n`);
|
|
15
18
|
return;
|
|
16
19
|
case "run-end":
|
|
17
20
|
this.summary(event.summary);
|
|
@@ -20,13 +23,17 @@ export class ConsoleReporter {
|
|
|
20
23
|
return;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
|
-
|
|
26
|
+
/** ` [chrome]` in a run with more than one browser, else nothing */
|
|
27
|
+
tag(browser) {
|
|
28
|
+
return this.labelled ? ` ${this.paint(90, `[${browser}]`)}` : "";
|
|
29
|
+
}
|
|
30
|
+
line(name, result, browser) {
|
|
24
31
|
const took = "duration" in result
|
|
25
32
|
? ` ${this.paint(90, `(${seconds(result.duration)})`)}`
|
|
26
33
|
: "";
|
|
27
34
|
return result.status === "success"
|
|
28
|
-
? `${this.paint(32, "✔")} ${name}${took}`
|
|
29
|
-
: `${this.paint(31, "✖")} ${name}${took}`;
|
|
35
|
+
? `${this.paint(32, "✔")} ${name}${this.tag(browser)}${took}`
|
|
36
|
+
: `${this.paint(31, "✖")} ${name}${this.tag(browser)}${took}`;
|
|
30
37
|
}
|
|
31
38
|
summary(summary) {
|
|
32
39
|
const failed = summary.tests.filter((test) => test.status !== "success");
|
|
@@ -34,7 +41,7 @@ export class ConsoleReporter {
|
|
|
34
41
|
if (!("name" in test))
|
|
35
42
|
continue;
|
|
36
43
|
const message = "message" in test ? test.message : "did not finish";
|
|
37
|
-
this.write(`\n${this.paint(31, `${test.name}`)}\n`);
|
|
44
|
+
this.write(`\n${this.paint(31, `${test.name}`)}${"browser" in test ? this.tag(test.browser) : ""}\n`);
|
|
38
45
|
this.write(`${message
|
|
39
46
|
.split("\n")
|
|
40
47
|
.map((line) => ` ${line}`)
|
package/dist/cli/init.js
CHANGED
package/dist/cli/main.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
|
|
1
|
+
export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
|
|
2
2
|
export interface Io {
|
|
3
3
|
out: (text: string) => void;
|
|
4
4
|
err: (text: string) => void;
|
package/dist/cli/main.js
CHANGED
|
@@ -22,6 +22,8 @@ Options for run and list:
|
|
|
22
22
|
--file <spec> Only this spec file; repeatable
|
|
23
23
|
--grep <text> Only tests whose full name contains the text
|
|
24
24
|
--group <name> Only the tests of this group of the config's groups
|
|
25
|
+
--browser <name> Run in this browser (firefox or chrome) instead of the config's
|
|
26
|
+
browsers; repeat it for more than one
|
|
25
27
|
--json Machine-readable output: JSON lines for run, one array for list
|
|
26
28
|
|
|
27
29
|
Options for run:
|
|
@@ -32,6 +34,7 @@ Options for run:
|
|
|
32
34
|
--port <n> Browser debugging port (default 9223)
|
|
33
35
|
|
|
34
36
|
Options for record (also --env, --port, --json, --headless, --no-headless):
|
|
37
|
+
--browser <name> Record in this browser (default: the first of the config's browsers)
|
|
35
38
|
--new <title> Add a test with this title (creates the spec file if needed)
|
|
36
39
|
--test <name|index> Record into this test; not needed when the file has one
|
|
37
40
|
--at <n> Insert from step n (default: after the last step)
|
|
@@ -99,6 +102,8 @@ export async function main(argv, io) {
|
|
|
99
102
|
if (at !== undefined && (!Number.isInteger(at) || at < 0))
|
|
100
103
|
throw new Error(`--at must be a step number, got "${values.at}"`);
|
|
101
104
|
const flags = parseRunnerFlags(rest);
|
|
105
|
+
if (flags.browsers && flags.browsers.length > 1)
|
|
106
|
+
throw new Error("--browser: a recording uses one browser");
|
|
102
107
|
const test = values.test === undefined
|
|
103
108
|
? undefined
|
|
104
109
|
: /^\d+$/.test(values.test)
|
|
@@ -116,6 +121,7 @@ export async function main(argv, io) {
|
|
|
116
121
|
...(values.url !== undefined && { url: values.url }),
|
|
117
122
|
...(flags.headless !== undefined && { headless: flags.headless }),
|
|
118
123
|
...(flags.port !== undefined && { port: flags.port }),
|
|
124
|
+
...(flags.browsers && { browser: flags.browsers[0] }),
|
|
119
125
|
...(io.signal && { signal: io.signal }),
|
|
120
126
|
onStarted: () => {
|
|
121
127
|
if (!values.json)
|
|
@@ -178,6 +184,7 @@ export async function main(argv, io) {
|
|
|
178
184
|
...(flags.grep !== undefined && { grep: flags.grep }),
|
|
179
185
|
...(flags.group !== undefined && { group: flags.group }),
|
|
180
186
|
...(flags.files && { files: flags.files }),
|
|
187
|
+
...(flags.browsers && { browsers: flags.browsers }),
|
|
181
188
|
};
|
|
182
189
|
if (command === "list") {
|
|
183
190
|
const tests = await listTests(options);
|
package/dist/config/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
3
|
import { pathToFileURL } from "url";
|
|
4
|
+
import { validateBrowserConfig } from "../runner/browsers.js";
|
|
4
5
|
let activeProject;
|
|
5
6
|
/** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
|
|
6
7
|
export function setActiveProject(project) {
|
|
@@ -22,14 +23,17 @@ export function browsersDir() {
|
|
|
22
23
|
}
|
|
23
24
|
/** The whole `samurai.config.ts` of the project (the working directory unless a project is active) */
|
|
24
25
|
export async function loadConfig() {
|
|
25
|
-
if (activeProject?.config)
|
|
26
|
+
if (activeProject?.config) {
|
|
27
|
+
validateBrowserConfig(activeProject.config);
|
|
26
28
|
return activeProject.config;
|
|
29
|
+
}
|
|
27
30
|
const configUrl = path.join(projectDir(), "samurai.config.ts");
|
|
28
31
|
logger.debug("Reading config from: %s", configUrl);
|
|
29
32
|
const config = (await import(pathToFileURL(configUrl).href));
|
|
30
33
|
if (!config.default) {
|
|
31
34
|
throw new Error("samurai.config.ts does not export defineConfig as default!");
|
|
32
35
|
}
|
|
36
|
+
validateBrowserConfig(config.default);
|
|
33
37
|
return config.default;
|
|
34
38
|
}
|
|
35
39
|
/** A project's config without keeping the project active (`withProject` is one at a time): the given one, else its samurai.config.ts */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AnyNodeDefinition, type ExpressionContext, type FlowFile, type FlowNodeModel } from "../../core/index.js";
|
|
2
2
|
import type { RunEvent } from "../../../runner/reporter.js";
|
|
3
|
+
import type { SupportedBrowser } from "../../../types/browser.js";
|
|
3
4
|
import type { NodeResult, NodeTest } from "../events.js";
|
|
4
5
|
/** What a handler can use of the run */
|
|
5
6
|
export interface HandlerRun {
|
|
@@ -10,8 +11,10 @@ export interface HandlerRun {
|
|
|
10
11
|
};
|
|
11
12
|
context: ExpressionContext;
|
|
12
13
|
nodeTests: Record<string, NodeTest[]>;
|
|
14
|
+
/** The browsers of the nodes that have their own (a Group node's group); the others use the project's */
|
|
15
|
+
nodeBrowsers?: Record<string, SupportedBrowser[]>;
|
|
13
16
|
signal: AbortSignal;
|
|
14
|
-
runTests: (tests: NodeTest[], onEvent: (e: RunEvent) => void) => Promise<"passed" | "failed" | "cancelled">;
|
|
17
|
+
runTests: (tests: NodeTest[], onEvent: (e: RunEvent) => void, browsers?: SupportedBrowser[]) => Promise<"passed" | "failed" | "cancelled">;
|
|
15
18
|
sleep: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
16
19
|
onTestEvent: (nodeId: string, e: RunEvent) => void;
|
|
17
20
|
/** Definitions of kinds core doesn't have (steps), for expression scopes */
|
|
@@ -7,7 +7,7 @@ export const fail = (e) => ({
|
|
|
7
7
|
export async function runNodeTests(node, run, list) {
|
|
8
8
|
let result;
|
|
9
9
|
try {
|
|
10
|
-
result = await run.runTests(list, (e) => run.onTestEvent(node.id, e));
|
|
10
|
+
result = await run.runTests(list, (e) => run.onTestEvent(node.id, e), run.nodeBrowsers?.[node.id]);
|
|
11
11
|
}
|
|
12
12
|
catch (e) {
|
|
13
13
|
return fail(e);
|
|
@@ -109,6 +109,7 @@ export async function runFlowInProject(options) {
|
|
|
109
109
|
// Phase 3: walk it
|
|
110
110
|
const byId = new Map(tests.map((t) => [t.id, t]));
|
|
111
111
|
const nodeTests = {};
|
|
112
|
+
const nodeBrowsers = {};
|
|
112
113
|
for (const node of flow.nodes) {
|
|
113
114
|
const ids = node.kind === "test"
|
|
114
115
|
? [node.ref?.testId ?? ""]
|
|
@@ -117,6 +118,11 @@ export async function runFlowInProject(options) {
|
|
|
117
118
|
: undefined;
|
|
118
119
|
if (!ids)
|
|
119
120
|
continue;
|
|
121
|
+
if (node.kind === "group") {
|
|
122
|
+
const browsers = groups.find((g) => g.name === node.ref?.group)?.browsers;
|
|
123
|
+
if (browsers)
|
|
124
|
+
nodeBrowsers[node.id] = browsers;
|
|
125
|
+
}
|
|
120
126
|
nodeTests[node.id] = ids.flatMap((id) => {
|
|
121
127
|
const t = byId.get(id);
|
|
122
128
|
return t ? [{ file: t.file, name: t.name }] : [];
|
|
@@ -126,6 +132,7 @@ export async function runFlowInProject(options) {
|
|
|
126
132
|
return runFlow({
|
|
127
133
|
flow,
|
|
128
134
|
nodeTests,
|
|
135
|
+
nodeBrowsers,
|
|
129
136
|
environment,
|
|
130
137
|
...(options.signal && { signal: options.signal }),
|
|
131
138
|
...(options.onEvent && { onEvent: options.onEvent }),
|
|
@@ -133,7 +140,7 @@ export async function runFlowInProject(options) {
|
|
|
133
140
|
unsupported,
|
|
134
141
|
handlers: { ...HANDLERS, ...steps.handlers },
|
|
135
142
|
nodes: steps.nodes,
|
|
136
|
-
runTests: async ({ tests, onEvent, signal }) => {
|
|
143
|
+
runTests: async ({ tests, onEvent, signal, browsers }) => {
|
|
137
144
|
const summary = await run({
|
|
138
145
|
projectDir,
|
|
139
146
|
config,
|
|
@@ -148,6 +155,7 @@ export async function runFlowInProject(options) {
|
|
|
148
155
|
expectTimeout: options.expectTimeout,
|
|
149
156
|
}),
|
|
150
157
|
...(options.port !== undefined && { port: options.port }),
|
|
158
|
+
...(browsers && { browsers }),
|
|
151
159
|
files: [...new Set(tests.map((t) => t.file))],
|
|
152
160
|
testNames: tests.map((t) => t.name),
|
|
153
161
|
reportPath: false,
|
package/dist/flows/run/run.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ import { type AnyNodeDefinition, type FlowFile } from "../core/index.js";
|
|
|
2
2
|
import type { RunEvent } from "../../runner/reporter.js";
|
|
3
3
|
import type { FlowEvent, FlowSummary, NodeTest } from "./events.js";
|
|
4
4
|
import { type Handler } from "./handlers/index.js";
|
|
5
|
+
import type { SupportedBrowser } from "../../types/browser.js";
|
|
5
6
|
export interface RunFlowOptions {
|
|
6
7
|
flow: FlowFile;
|
|
7
8
|
/** The tests of each Test and Group node, by node id */
|
|
8
9
|
nodeTests: Record<string, NodeTest[]>;
|
|
10
|
+
/** The browsers a Group node runs in (its group's); a node not listed uses the project's */
|
|
11
|
+
nodeBrowsers?: Record<string, SupportedBrowser[]>;
|
|
9
12
|
environment: {
|
|
10
13
|
name: string;
|
|
11
14
|
variables: Record<string, string | number | boolean>;
|
|
@@ -20,6 +23,8 @@ export interface RunFlowOptions {
|
|
|
20
23
|
tests: NodeTest[];
|
|
21
24
|
onEvent: (e: RunEvent) => void;
|
|
22
25
|
signal: AbortSignal;
|
|
26
|
+
/** Set for a node that runs in browsers of its own */
|
|
27
|
+
browsers?: SupportedBrowser[];
|
|
23
28
|
}) => Promise<"passed" | "failed" | "cancelled">;
|
|
24
29
|
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
25
30
|
/**
|
package/dist/flows/run/run.js
CHANGED
|
@@ -65,9 +65,15 @@ export async function runFlow(options) {
|
|
|
65
65
|
environment: options.environment,
|
|
66
66
|
context,
|
|
67
67
|
nodeTests: options.nodeTests,
|
|
68
|
+
...(options.nodeBrowsers && { nodeBrowsers: options.nodeBrowsers }),
|
|
68
69
|
signal,
|
|
69
70
|
sleep: options.sleep ?? defaultSleep,
|
|
70
|
-
runTests: (tests, onTestEvent) => options.runTests({
|
|
71
|
+
runTests: (tests, onTestEvent, browsers) => options.runTests({
|
|
72
|
+
tests,
|
|
73
|
+
onEvent: onTestEvent,
|
|
74
|
+
signal,
|
|
75
|
+
...(browsers && { browsers }),
|
|
76
|
+
}),
|
|
71
77
|
onTestEvent: (nodeId, event) => onEvent({ type: "test", nodeId, event }),
|
|
72
78
|
nodes,
|
|
73
79
|
log: (nodeId, level, text) => logger[level](`[${nodeId}] ${text}`),
|
package/dist/locator/locator.js
CHANGED
|
@@ -88,6 +88,16 @@ const NAMED_KEYS = {
|
|
|
88
88
|
};
|
|
89
89
|
/** Unicode code point WebDriver uses for the Backspace key */
|
|
90
90
|
const BACKSPACE = "";
|
|
91
|
+
/**
|
|
92
|
+
* Chrome answers a command sent while the page swaps documents with an unknown error that says the context
|
|
93
|
+
* is gone for the moment; the same call a moment later works.
|
|
94
|
+
*/
|
|
95
|
+
const DOCUMENT_SWAP = /Cannot find context with specified id|execution contexts? (was |were )?(cleared|destroyed)/i;
|
|
96
|
+
function isDocumentSwap(err) {
|
|
97
|
+
return (err instanceof BiDiError &&
|
|
98
|
+
err.code === "unknown error" &&
|
|
99
|
+
DOCUMENT_SWAP.test(err.message));
|
|
100
|
+
}
|
|
91
101
|
export default class Locator {
|
|
92
102
|
/**
|
|
93
103
|
* `selector` is an xpath, or a chain of selector steps that each search inside the previous step's matches.
|
|
@@ -216,7 +226,15 @@ export default class Locator {
|
|
|
216
226
|
nodes = picked ? [picked] : [];
|
|
217
227
|
}
|
|
218
228
|
else {
|
|
219
|
-
|
|
229
|
+
try {
|
|
230
|
+
nodes = await this.locateStep(selector, index > 0, nodes, index === this.chain.length - 1 ? max : undefined);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
// The page is swapping documents: nothing matches right now, and the waits that poll try again
|
|
234
|
+
if (isDocumentSwap(err))
|
|
235
|
+
return [];
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
220
238
|
}
|
|
221
239
|
if (nodes.length === 0)
|
|
222
240
|
return [];
|
|
@@ -335,7 +353,8 @@ export default class Locator {
|
|
|
335
353
|
}
|
|
336
354
|
catch (err) {
|
|
337
355
|
// The document changed between the search and the probe
|
|
338
|
-
if (err instanceof BiDiError && err.code === "no such node")
|
|
356
|
+
if ((err instanceof BiDiError && err.code === "no such node") ||
|
|
357
|
+
isDocumentSwap(err))
|
|
339
358
|
return DETACHED_STATE;
|
|
340
359
|
throw err;
|
|
341
360
|
}
|
|
@@ -12,6 +12,7 @@ export declare class ResponseBodyUnavailableError extends Error {
|
|
|
12
12
|
export declare class DataCollector {
|
|
13
13
|
private connector;
|
|
14
14
|
private id;
|
|
15
|
+
private maxBodySize;
|
|
15
16
|
private disposed;
|
|
16
17
|
private constructor();
|
|
17
18
|
static start(connector: BiDiConnector, contextId: string, options?: {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { BiDiError } from "../transport/bidi-error.js";
|
|
2
2
|
/** How large a response body may be to be kept: 10 MiB */
|
|
3
3
|
const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
4
|
+
/**
|
|
5
|
+
* Chrome counts the headers of a response in the size it compares with the collector's limit (a 14-byte body
|
|
6
|
+
* arrives as about 200 bytes), Firefox counts the body. Chrome's limit is raised by this much and the body is
|
|
7
|
+
* measured after it is read.
|
|
8
|
+
*/
|
|
9
|
+
const CHROME_HEADER_ALLOWANCE = 64 * 1024;
|
|
4
10
|
/** The body of a response cannot be read */
|
|
5
11
|
export class ResponseBodyUnavailableError extends Error {
|
|
6
12
|
constructor(url, reason) {
|
|
@@ -15,18 +21,22 @@ export class ResponseBodyUnavailableError extends Error {
|
|
|
15
21
|
* started. This registers one for a page and reads bodies through it.
|
|
16
22
|
*/
|
|
17
23
|
export class DataCollector {
|
|
18
|
-
constructor(connector, id) {
|
|
24
|
+
constructor(connector, id, maxBodySize) {
|
|
19
25
|
this.connector = connector;
|
|
20
26
|
this.id = id;
|
|
27
|
+
this.maxBodySize = maxBodySize;
|
|
21
28
|
this.disposed = false;
|
|
22
29
|
}
|
|
23
30
|
static async start(connector, contextId, options = {}) {
|
|
31
|
+
const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
|
|
24
32
|
const { collector } = await connector.send("network.addDataCollector", {
|
|
25
33
|
dataTypes: ["response"],
|
|
26
|
-
maxEncodedDataSize:
|
|
34
|
+
maxEncodedDataSize: connector.browserName === "chrome"
|
|
35
|
+
? maxBodySize + CHROME_HEADER_ALLOWANCE
|
|
36
|
+
: maxBodySize,
|
|
27
37
|
contexts: [contextId],
|
|
28
38
|
});
|
|
29
|
-
return new DataCollector(connector, collector);
|
|
39
|
+
return new DataCollector(connector, collector, maxBodySize);
|
|
30
40
|
}
|
|
31
41
|
/** The body of the response to request `requestId`; `url` only names it in errors */
|
|
32
42
|
async read(requestId, url) {
|
|
@@ -36,7 +46,13 @@ export class DataCollector {
|
|
|
36
46
|
request: requestId,
|
|
37
47
|
collector: this.id,
|
|
38
48
|
});
|
|
39
|
-
|
|
49
|
+
const body = bytes.type === "base64"
|
|
50
|
+
? Buffer.from(bytes.value, "base64")
|
|
51
|
+
: Buffer.from(bytes.value, "utf8");
|
|
52
|
+
if (body.length > this.maxBodySize) {
|
|
53
|
+
throw new ResponseBodyUnavailableError(url, `the response is larger than maxBodySize (${this.maxBodySize} bytes)`);
|
|
54
|
+
}
|
|
55
|
+
return body;
|
|
40
56
|
}
|
|
41
57
|
catch (err) {
|
|
42
58
|
if (err instanceof BiDiError && err.code === "no such network data") {
|
|
@@ -67,7 +83,9 @@ export class DataCollector {
|
|
|
67
83
|
return;
|
|
68
84
|
this.disposed = true;
|
|
69
85
|
try {
|
|
70
|
-
await this.connector.send("network.removeDataCollector", {
|
|
86
|
+
await this.connector.send("network.removeDataCollector", {
|
|
87
|
+
collector: this.id,
|
|
88
|
+
});
|
|
71
89
|
}
|
|
72
90
|
catch (err) {
|
|
73
91
|
if (!(err instanceof BiDiError && err.code === "no such network collector"))
|
|
@@ -54,6 +54,8 @@ export declare class NetworkTracker {
|
|
|
54
54
|
private connector;
|
|
55
55
|
private events;
|
|
56
56
|
private active;
|
|
57
|
+
/** The context each request in flight belongs to, by the same key */
|
|
58
|
+
private contexts;
|
|
57
59
|
/** Requests that may still be the source of a redirect, by hop key */
|
|
58
60
|
private hops;
|
|
59
61
|
private waiters;
|
|
@@ -73,7 +75,7 @@ export declare class NetworkTracker {
|
|
|
73
75
|
* Resolves once no request has been in flight for `idleTime` ms. A quiet page resolves after `idleTime`.
|
|
74
76
|
* Rejects with NetworkIdleTimeoutError after `timeout` ms, or with "page closed" when disposed.
|
|
75
77
|
*/
|
|
76
|
-
waitForIdle({ idleTime, timeout }: {
|
|
78
|
+
waitForIdle({ idleTime, timeout, }: {
|
|
77
79
|
idleTime: number;
|
|
78
80
|
timeout: number;
|
|
79
81
|
}): Promise<void>;
|
|
@@ -85,6 +87,11 @@ export declare class NetworkTracker {
|
|
|
85
87
|
private onBeforeRequestSent;
|
|
86
88
|
private onResponseCompleted;
|
|
87
89
|
private onFetchError;
|
|
90
|
+
/**
|
|
91
|
+
* A document replaced another: the requests of the old one that are still in flight are over. Firefox reports each
|
|
92
|
+
* as failed (NS_BINDING_ABORTED); Chrome says nothing, which would keep the page "busy" for good.
|
|
93
|
+
*/
|
|
94
|
+
private onNavigationCommitted;
|
|
88
95
|
/** A listener that throws must not stop the tracker or the other listeners */
|
|
89
96
|
private emit;
|
|
90
97
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
3
|
import { WaitTimeoutError } from "../wait/wait-until.js";
|
|
4
|
+
/** The error text of a request that the page left before it finished, when the browser says none */
|
|
5
|
+
const ABORTED_BY_NAVIGATION = "aborted by navigation";
|
|
4
6
|
/** How many URLs a timeout message names */
|
|
5
7
|
const URLS_IN_MESSAGE = 5;
|
|
6
8
|
/** waitForIdle ran out of time with requests still in flight; `last` holds (up to five of) their URLs */
|
|
@@ -24,6 +26,8 @@ export class NetworkTracker {
|
|
|
24
26
|
this.connector = connector;
|
|
25
27
|
this.events = new EventEmitter();
|
|
26
28
|
this.active = new Map();
|
|
29
|
+
/** The context each request in flight belongs to, by the same key */
|
|
30
|
+
this.contexts = new Map();
|
|
27
31
|
/** Requests that may still be the source of a redirect, by hop key */
|
|
28
32
|
this.hops = new Map();
|
|
29
33
|
this.waiters = new Set();
|
|
@@ -41,6 +45,8 @@ export class NetworkTracker {
|
|
|
41
45
|
return;
|
|
42
46
|
this.hops.set(key, request);
|
|
43
47
|
this.active.set(key, request.url);
|
|
48
|
+
if (params.context)
|
|
49
|
+
this.contexts.set(key, params.context);
|
|
44
50
|
this.emit("request", request);
|
|
45
51
|
this.events.emit("change");
|
|
46
52
|
};
|
|
@@ -51,6 +57,7 @@ export class NetworkTracker {
|
|
|
51
57
|
const request = this.hops.get(key);
|
|
52
58
|
if (!this.active.delete(key) || !request)
|
|
53
59
|
return;
|
|
60
|
+
this.contexts.delete(key);
|
|
54
61
|
const { response } = params;
|
|
55
62
|
// A redirect response keeps its request around: the next hop links back to it
|
|
56
63
|
if (response.status < 300 || response.status >= 400)
|
|
@@ -72,10 +79,34 @@ export class NetworkTracker {
|
|
|
72
79
|
const request = this.hops.get(key);
|
|
73
80
|
if (!this.active.delete(key) || !request)
|
|
74
81
|
return;
|
|
82
|
+
this.contexts.delete(key);
|
|
75
83
|
this.hops.delete(key);
|
|
76
84
|
this.emit("requestfailed", Object.assign(request, { errorText: params.errorText }));
|
|
77
85
|
this.events.emit("change");
|
|
78
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* A document replaced another: the requests of the old one that are still in flight are over. Firefox reports each
|
|
89
|
+
* as failed (NS_BINDING_ABORTED); Chrome says nothing, which would keep the page "busy" for good.
|
|
90
|
+
*/
|
|
91
|
+
this.onNavigationCommitted = (params) => {
|
|
92
|
+
if (!this.owns(params.context))
|
|
93
|
+
return;
|
|
94
|
+
let changed = false;
|
|
95
|
+
for (const [key, requestContext] of [...this.contexts]) {
|
|
96
|
+
const request = this.hops.get(key);
|
|
97
|
+
if (!request || request.navigation === params.navigation)
|
|
98
|
+
continue;
|
|
99
|
+
if (!this.tree.isWithin(requestContext, params.context))
|
|
100
|
+
continue;
|
|
101
|
+
this.contexts.delete(key);
|
|
102
|
+
this.active.delete(key);
|
|
103
|
+
this.hops.delete(key);
|
|
104
|
+
this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
if (changed)
|
|
108
|
+
this.events.emit("change");
|
|
109
|
+
};
|
|
79
110
|
}
|
|
80
111
|
static async start(connector, tree, contextId, options = {}) {
|
|
81
112
|
const tracker = new NetworkTracker(tree, contextId, options.ignore ?? (() => false), connector);
|
|
@@ -83,11 +114,13 @@ export class NetworkTracker {
|
|
|
83
114
|
connector.onEvent("network.beforeRequestSent", tracker.onBeforeRequestSent);
|
|
84
115
|
connector.onEvent("network.responseCompleted", tracker.onResponseCompleted);
|
|
85
116
|
connector.onEvent("network.fetchError", tracker.onFetchError);
|
|
117
|
+
connector.onEvent("browsingContext.navigationCommitted", tracker.onNavigationCommitted);
|
|
86
118
|
try {
|
|
87
119
|
tracker.subscription = await connector.subscribe([
|
|
88
120
|
"network.beforeRequestSent",
|
|
89
121
|
"network.responseCompleted",
|
|
90
122
|
"network.fetchError",
|
|
123
|
+
"browsingContext.navigationCommitted",
|
|
91
124
|
]);
|
|
92
125
|
}
|
|
93
126
|
catch (err) {
|
|
@@ -118,10 +151,12 @@ export class NetworkTracker {
|
|
|
118
151
|
* Resolves once no request has been in flight for `idleTime` ms. A quiet page resolves after `idleTime`.
|
|
119
152
|
* Rejects with NetworkIdleTimeoutError after `timeout` ms, or with "page closed" when disposed.
|
|
120
153
|
*/
|
|
121
|
-
waitForIdle({ idleTime, timeout }) {
|
|
154
|
+
waitForIdle({ idleTime, timeout, }) {
|
|
122
155
|
return new Promise((resolve, reject) => {
|
|
123
156
|
let idleTimer;
|
|
124
|
-
const waiter = {
|
|
157
|
+
const waiter = {
|
|
158
|
+
reject: (reason) => finish(() => reject(reason)),
|
|
159
|
+
};
|
|
125
160
|
const finish = (settle) => {
|
|
126
161
|
clearTimeout(idleTimer);
|
|
127
162
|
clearTimeout(deadline);
|
|
@@ -147,6 +182,7 @@ export class NetworkTracker {
|
|
|
147
182
|
this.disposed = true;
|
|
148
183
|
this.removeListeners();
|
|
149
184
|
this.active.clear();
|
|
185
|
+
this.contexts.clear();
|
|
150
186
|
this.hops.clear();
|
|
151
187
|
[...this.waiters].forEach((waiter) => waiter.reject(new Error("page closed")));
|
|
152
188
|
await this.subscription?.unsubscribe();
|
|
@@ -155,6 +191,7 @@ export class NetworkTracker {
|
|
|
155
191
|
this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
156
192
|
this.connector.offEvent("network.responseCompleted", this.onResponseCompleted);
|
|
157
193
|
this.connector.offEvent("network.fetchError", this.onFetchError);
|
|
194
|
+
this.connector.offEvent("browsingContext.navigationCommitted", this.onNavigationCommitted);
|
|
158
195
|
}
|
|
159
196
|
owns(context) {
|
|
160
197
|
return context !== null && this.tree.isWithin(context, this.contextId);
|
|
@@ -190,7 +227,9 @@ export function toRequest(data, navigation, isBlocked, redirectedFrom) {
|
|
|
190
227
|
export function toHeaders(headers) {
|
|
191
228
|
const result = {};
|
|
192
229
|
for (const { name, value } of headers) {
|
|
193
|
-
const text = value.type === "base64"
|
|
230
|
+
const text = value.type === "base64"
|
|
231
|
+
? Buffer.from(value.value, "base64").toString("utf8")
|
|
232
|
+
: value.value;
|
|
194
233
|
const key = name.toLowerCase();
|
|
195
234
|
result[key] = key in result ? `${result[key]}, ${text}` : text;
|
|
196
235
|
}
|
package/dist/network/router.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ interface RouteDeps {
|
|
|
35
35
|
next: () => Promise<void>;
|
|
36
36
|
/** Called once the route is answered, whichever way */
|
|
37
37
|
settled: (route: Route) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a replaced body needs a `content-length` of its own. Firefox keeps the old one and cuts the new body
|
|
40
|
+
* off; Chrome sets the right one itself and hangs the request when it is given one.
|
|
41
|
+
*/
|
|
42
|
+
sendsContentLength: boolean;
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
45
|
* A request the browser holds until it is answered. Answer it exactly once with `fulfill`, `continue`,
|