@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.
Files changed (66) hide show
  1. package/README.md +9 -9
  2. package/dist/browser/browser-finder.js +2 -2
  3. package/dist/browser/browser.d.ts +11 -15
  4. package/dist/browser/browser.js +79 -58
  5. package/dist/browser/chrome-launch.d.ts +69 -0
  6. package/dist/browser/chrome-launch.js +170 -0
  7. package/dist/browser/download-folders.d.ts +27 -0
  8. package/dist/browser/download-folders.js +97 -0
  9. package/dist/browser/page-logs.d.ts +2 -0
  10. package/dist/browser/page-logs.js +25 -5
  11. package/dist/browser/page.d.ts +18 -0
  12. package/dist/browser/page.js +99 -2
  13. package/dist/browser/screenshot.js +16 -2
  14. package/dist/browser/session-capabilities.d.ts +12 -0
  15. package/dist/browser/session-capabilities.js +8 -0
  16. package/dist/browsers/index.d.ts +2 -2
  17. package/dist/browsers/index.js +2 -2
  18. package/dist/browsers/overrides.d.ts +2 -0
  19. package/dist/browsers/overrides.js +2 -0
  20. package/dist/browsers/pins.d.ts +1 -1
  21. package/dist/browsers/pins.js +2 -2
  22. package/dist/browsers/resolve.d.ts +2 -0
  23. package/dist/browsers/resolve.js +14 -2
  24. package/dist/browsers/store.d.ts +2 -0
  25. package/dist/browsers/store.js +8 -0
  26. package/dist/cli/args.d.ts +7 -2
  27. package/dist/cli/args.js +7 -1
  28. package/dist/cli/console-reporter.d.ts +4 -0
  29. package/dist/cli/console-reporter.js +13 -6
  30. package/dist/cli/init.js +1 -1
  31. package/dist/cli/main.d.ts +1 -1
  32. package/dist/cli/main.js +7 -0
  33. package/dist/config/config.js +5 -1
  34. package/dist/flows/run/handlers/support.d.ts +4 -1
  35. package/dist/flows/run/handlers/support.js +1 -1
  36. package/dist/flows/run/project.js +9 -1
  37. package/dist/flows/run/run.d.ts +5 -0
  38. package/dist/flows/run/run.js +7 -1
  39. package/dist/locator/locator.js +21 -2
  40. package/dist/network/data-collector.d.ts +1 -0
  41. package/dist/network/data-collector.js +23 -5
  42. package/dist/network/network-tracker.d.ts +8 -1
  43. package/dist/network/network-tracker.js +42 -3
  44. package/dist/network/router.d.ts +5 -0
  45. package/dist/network/router.js +33 -11
  46. package/dist/recorder/session.d.ts +3 -0
  47. package/dist/recorder/session.js +7 -3
  48. package/dist/recorder/toolbar/controller.d.ts +6 -5
  49. package/dist/recorder/toolbar/controller.js +39 -7
  50. package/dist/runner/browsers.d.ts +22 -0
  51. package/dist/runner/browsers.js +44 -0
  52. package/dist/runner/groups.d.ts +5 -1
  53. package/dist/runner/groups.js +9 -2
  54. package/dist/runner/reporter.d.ts +10 -2
  55. package/dist/runner/reporter.js +30 -10
  56. package/dist/runner/run.d.ts +1 -0
  57. package/dist/runner/run.js +1 -0
  58. package/dist/runner/test-runner.d.ts +9 -1
  59. package/dist/runner/test-runner.js +46 -19
  60. package/dist/script/preload.js +65 -0
  61. package/dist/transport/bidi-connection.d.ts +19 -0
  62. package/dist/transport/bidi-connection.js +34 -1
  63. package/dist/types/browser.d.ts +1 -1
  64. package/dist/types/config.d.ts +5 -3
  65. package/dist/types/test.d.ts +8 -0
  66. 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.write(`Running ${event.total} ${event.total === 1 ? "test" : "tests"} against ${event.environment}\n`);
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
- line(name, result) {
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
@@ -23,7 +23,7 @@ export function scaffold(name) {
23
23
 
24
24
  export default defineConfig({
25
25
  srcDir: "./tests",
26
- browser: "firefox",
26
+ browsers: ["firefox"],
27
27
  timeout: 30000,
28
28
  environments: {
29
29
  local: { baseURL: "http://localhost:3000" },
@@ -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);
@@ -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,
@@ -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
  /**
@@ -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({ tests, onEvent: onTestEvent, signal }),
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}`),
@@ -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
- nodes = await this.locateStep(selector, index > 0, nodes, index === this.chain.length - 1 ? max : undefined);
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: options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE,
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
- return bytes.type === "base64" ? Buffer.from(bytes.value, "base64") : Buffer.from(bytes.value, "utf8");
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", { collector: this.id });
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 = { reject: (reason) => finish(() => reject(reason)) };
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" ? Buffer.from(value.value, "base64").toString("utf8") : value.value;
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
  }
@@ -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`,