@itmetsam/samurai-framework 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +31 -12
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/browser-finder.js +2 -2
  5. package/dist/browser/browser.d.ts +11 -15
  6. package/dist/browser/browser.js +79 -58
  7. package/dist/browser/chrome-launch.d.ts +57 -0
  8. package/dist/browser/chrome-launch.js +127 -0
  9. package/dist/browser/download-folders.d.ts +27 -0
  10. package/dist/browser/download-folders.js +97 -0
  11. package/dist/browser/file-chooser.d.ts +4 -3
  12. package/dist/browser/file-chooser.js +9 -8
  13. package/dist/browser/generated-files.d.ts +30 -0
  14. package/dist/browser/generated-files.js +116 -0
  15. package/dist/browser/page-logs.d.ts +2 -0
  16. package/dist/browser/page-logs.js +25 -5
  17. package/dist/browser/page.d.ts +37 -0
  18. package/dist/browser/page.js +129 -2
  19. package/dist/browser/screenshot.js +16 -2
  20. package/dist/browser/session-capabilities.d.ts +12 -0
  21. package/dist/browser/session-capabilities.js +8 -0
  22. package/dist/browsers/index.d.ts +2 -2
  23. package/dist/browsers/index.js +2 -2
  24. package/dist/browsers/overrides.d.ts +2 -0
  25. package/dist/browsers/overrides.js +2 -0
  26. package/dist/browsers/pins.d.ts +1 -1
  27. package/dist/browsers/pins.js +2 -2
  28. package/dist/browsers/resolve.d.ts +2 -0
  29. package/dist/browsers/resolve.js +14 -2
  30. package/dist/browsers/store.d.ts +2 -0
  31. package/dist/browsers/store.js +8 -0
  32. package/dist/cli/main.d.ts +1 -1
  33. package/dist/cli/main.js +20 -5
  34. package/dist/locator/locator.d.ts +17 -5
  35. package/dist/locator/locator.js +77 -10
  36. package/dist/network/data-collector.d.ts +1 -0
  37. package/dist/network/data-collector.js +23 -5
  38. package/dist/network/network-tracker.d.ts +8 -1
  39. package/dist/network/network-tracker.js +42 -3
  40. package/dist/network/router.d.ts +5 -0
  41. package/dist/network/router.js +33 -11
  42. package/dist/project/files-store.d.ts +28 -0
  43. package/dist/project/files-store.js +106 -0
  44. package/dist/project/index.d.ts +1 -0
  45. package/dist/project/index.js +1 -0
  46. package/dist/recorder/apply.js +8 -6
  47. package/dist/recorder/files-capture.d.ts +26 -0
  48. package/dist/recorder/files-capture.js +92 -0
  49. package/dist/recorder/files-reader.d.ts +7 -0
  50. package/dist/recorder/files-reader.js +25 -0
  51. package/dist/recorder/index.d.ts +1 -1
  52. package/dist/recorder/index.js +1 -1
  53. package/dist/recorder/normalise.d.ts +26 -2
  54. package/dist/recorder/normalise.js +48 -1
  55. package/dist/recorder/recorder.d.ts +38 -0
  56. package/dist/recorder/recorder.js +160 -1
  57. package/dist/recorder/session.d.ts +14 -1
  58. package/dist/recorder/session.js +36 -9
  59. package/dist/recorder/toolbar/controller.d.ts +15 -5
  60. package/dist/recorder/toolbar/controller.js +61 -7
  61. package/dist/recorder/toolbar/protocol.d.ts +10 -0
  62. package/dist/recorder/toolbar/source.d.ts +1 -1
  63. package/dist/recorder/toolbar/source.js +35 -2
  64. package/dist/recorder/toolbar/styles.d.ts +1 -1
  65. package/dist/recorder/toolbar/styles.js +31 -0
  66. package/dist/runner/project.js +2 -0
  67. package/dist/script/preload.js +65 -0
  68. package/dist/steps/edit.js +38 -3
  69. package/dist/steps/emit.d.ts +3 -1
  70. package/dist/steps/emit.js +19 -0
  71. package/dist/steps/files.d.ts +8 -0
  72. package/dist/steps/files.js +33 -0
  73. package/dist/steps/index.d.ts +3 -2
  74. package/dist/steps/index.js +2 -1
  75. package/dist/steps/model.d.ts +28 -0
  76. package/dist/steps/parse.js +79 -0
  77. package/dist/transport/bidi-connection.d.ts +6 -0
  78. package/dist/transport/bidi-connection.js +13 -0
  79. package/dist/types/browser.d.ts +1 -1
  80. package/dist/types/config.d.ts +7 -2
  81. package/package.json +3 -2
  82. package/dist/browser/file-paths.d.ts +0 -4
  83. package/dist/browser/file-paths.js +0 -18
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Sent with session.new (Firefox) or as capabilities of the chromedriver session (Chrome). With "ignore" the browser
3
+ * leaves a JavaScript dialog open and reports it (`browsingContext.userPromptOpened`), so the framework decides how
4
+ * to answer instead of the browser.
5
+ */
6
+ export declare const SESSION_CAPABILITIES: {
7
+ alwaysMatch: {
8
+ unhandledPromptBehavior: {
9
+ default: string;
10
+ };
11
+ };
12
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Sent with session.new (Firefox) or as capabilities of the chromedriver session (Chrome). With "ignore" the browser
3
+ * leaves a JavaScript dialog open and reports it (`browsingContext.userPromptOpened`), so the framework decides how
4
+ * to answer instead of the browser.
5
+ */
6
+ export const SESSION_CAPABILITIES = {
7
+ alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } },
8
+ };
@@ -1,6 +1,6 @@
1
1
  export { browsersCacheDir } from "./cache-dir.js";
2
2
  export type { CachedBuild, Downloader } from "./downloader.js";
3
- export { ENV_PATH, type Override } from "./overrides.js";
3
+ export { ENV_CHROMEDRIVER_PATH, ENV_PATH, type Override } from "./overrides.js";
4
4
  export { LAUNCHABLE, MANAGED_BROWSERS, PINNED, isManagedBrowser, type ManagedBrowser, } from "./pins.js";
5
5
  export { browserStatus, projectBrowser, projectExecutable, resolveBrowser, resolveExecutable, type ResolvedBrowser, } from "./resolve.js";
6
- export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedExecutable, uninstallBrowser, type BrowserStatus, type InstalledBuild, type StoreOptions, } from "./store.js";
6
+ export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedChromedriver, managedExecutable, uninstallBrowser, type BrowserStatus, type InstalledBuild, type StoreOptions, } from "./store.js";
@@ -1,5 +1,5 @@
1
1
  export { browsersCacheDir } from "./cache-dir.js";
2
- export { ENV_PATH } from "./overrides.js";
2
+ export { ENV_CHROMEDRIVER_PATH, ENV_PATH } from "./overrides.js";
3
3
  export { LAUNCHABLE, MANAGED_BROWSERS, PINNED, isManagedBrowser, } from "./pins.js";
4
4
  export { browserStatus, projectBrowser, projectExecutable, resolveBrowser, resolveExecutable, } from "./resolve.js";
5
- export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedExecutable, uninstallBrowser, } from "./store.js";
5
+ export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedChromedriver, managedExecutable, uninstallBrowser, } from "./store.js";
@@ -12,6 +12,8 @@ export interface OverrideSources {
12
12
  /** What relative `browserPaths` resolve from */
13
13
  projectDir?: string;
14
14
  }
15
+ /** Chrome is driven through a chromedriver of the same major version; this points a launch at another one */
16
+ export declare const ENV_CHROMEDRIVER_PATH = "SAMURAI_CHROMEDRIVER_PATH";
15
17
  export declare const ENV_PATH: Record<ManagedBrowser, string>;
16
18
  /** The env variable, else the config's `browserPaths`; undefined: use the managed build */
17
19
  export declare function overrideOf(name: ManagedBrowser, { env, browserPaths, projectDir, }?: OverrideSources): Override | undefined;
@@ -1,4 +1,6 @@
1
1
  import path from "node:path";
2
+ /** Chrome is driven through a chromedriver of the same major version; this points a launch at another one */
3
+ export const ENV_CHROMEDRIVER_PATH = "SAMURAI_CHROMEDRIVER_PATH";
2
4
  export const ENV_PATH = {
3
5
  firefox: "SAMURAI_FIREFOX_PATH",
4
6
  chrome: "SAMURAI_CHROME_PATH",
@@ -11,6 +11,6 @@ export declare const PINNED: {
11
11
  };
12
12
  export type ManagedBrowser = keyof typeof PINNED;
13
13
  export declare const MANAGED_BROWSERS: ManagedBrowser[];
14
- /** The managed browsers the framework can launch; Chrome joins when it can drive it */
14
+ /** The managed browsers the framework can launch (Chrome through its chromedriver) */
15
15
  export declare const LAUNCHABLE: readonly ManagedBrowser[];
16
16
  export declare function isManagedBrowser(name: string): name is ManagedBrowser;
@@ -7,8 +7,8 @@ export const PINNED = {
7
7
  },
8
8
  };
9
9
  export const MANAGED_BROWSERS = Object.keys(PINNED);
10
- /** The managed browsers the framework can launch; Chrome joins when it can drive it */
11
- export const LAUNCHABLE = ["firefox"];
10
+ /** The managed browsers the framework can launch (Chrome through its chromedriver) */
11
+ export const LAUNCHABLE = ["firefox", "chrome"];
12
12
  export function isManagedBrowser(name) {
13
13
  return Object.prototype.hasOwnProperty.call(PINNED, name);
14
14
  }
@@ -9,6 +9,8 @@ export interface ResolvedBrowser {
9
9
  label: string;
10
10
  /** The managed build's folder; undefined when an env or config path is used instead */
11
11
  buildDir?: string;
12
+ /** Chrome only: the chromedriver that starts it and speaks BiDi for it */
13
+ driverPath?: string;
12
14
  }
13
15
  /** The browser a launch uses: the env path, else the config's `browserPaths`, else the pinned build. Never downloads */
14
16
  export declare function resolveBrowser(name: ManagedBrowser, sources?: OverrideSources & StoreOptions): ResolvedBrowser;
@@ -1,17 +1,19 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { projectConfig, projectDir, readConfig } from "../config/config.js";
3
3
  import logger from "../logger/index.js";
4
- import { ENV_PATH, overrideOf } from "./overrides.js";
4
+ import { ENV_CHROMEDRIVER_PATH, ENV_PATH, overrideOf, } from "./overrides.js";
5
5
  import { MANAGED_BROWSERS, PINNED } from "./pins.js";
6
- import { managedBuildDir, managedExecutable, statusOf, } from "./store.js";
6
+ import { managedBuildDir, managedChromedriver, managedExecutable, statusOf, } from "./store.js";
7
7
  /** The browser a launch uses: the env path, else the config's `browserPaths`, else the pinned build. Never downloads */
8
8
  export function resolveBrowser(name, sources = {}) {
9
+ const driverPath = name === "chrome" ? resolveChromedriver(sources) : undefined;
9
10
  const override = overrideOf(name, sources);
10
11
  if (!override)
11
12
  return {
12
13
  executablePath: managedExecutable(name, sources),
13
14
  label: PINNED[name].label,
14
15
  buildDir: managedBuildDir(name, sources),
16
+ ...(driverPath && { driverPath }),
15
17
  };
16
18
  if (!existsSync(override.path))
17
19
  throw new Error(override.from === "env"
@@ -20,8 +22,18 @@ export function resolveBrowser(name, sources = {}) {
20
22
  return {
21
23
  executablePath: override.path,
22
24
  label: name.charAt(0).toUpperCase() + name.slice(1),
25
+ ...(driverPath && { driverPath }),
23
26
  };
24
27
  }
28
+ /** `SAMURAI_CHROMEDRIVER_PATH` (which must exist), else the chromedriver installed with the pinned Chrome */
29
+ function resolveChromedriver(sources) {
30
+ const fromEnv = (sources.env ?? process.env)[ENV_CHROMEDRIVER_PATH];
31
+ if (!fromEnv)
32
+ return managedChromedriver(sources);
33
+ if (!existsSync(fromEnv))
34
+ throw new Error(`${ENV_CHROMEDRIVER_PATH} points to a missing file: ${fromEnv}`);
35
+ return fromEnv;
36
+ }
25
37
  /** The binary a launch uses; see `resolveBrowser` */
26
38
  export function resolveExecutable(name, sources = {}) {
27
39
  return resolveBrowser(name, sources).executablePath;
@@ -44,6 +44,8 @@ export declare function installBrowser(name: ManagedBrowser, options?: StoreOpti
44
44
  export declare function statusOf(name: ManagedBrowser, options?: StoreOptions & OverrideSources): BrowserStatus;
45
45
  /** The pinned build's executable; throws BrowserNotInstalledError when it isn't complete */
46
46
  export declare function managedExecutable(name: ManagedBrowser, options?: StoreOptions): string;
47
+ /** The chromedriver installed with the pinned Chrome; throws BrowserNotInstalledError when Chrome's install isn't complete */
48
+ export declare function managedChromedriver(options?: StoreOptions): string;
47
49
  /** The folder the pinned build of `name` is, or would be, installed in */
48
50
  export declare function managedBuildDir(name: ManagedBrowser, options?: StoreOptions): string;
49
51
  /** Removes a build (default: the pinned one); Chrome takes its chromedriver along */
@@ -127,6 +127,14 @@ export function managedExecutable(name, options = {}) {
127
127
  throw new BrowserNotInstalledError(name, status.label);
128
128
  return status.executablePath;
129
129
  }
130
+ /** The chromedriver installed with the pinned Chrome; throws BrowserNotInstalledError when Chrome's install isn't complete */
131
+ export function managedChromedriver(options = {}) {
132
+ const { cacheDir, downloader } = store(options);
133
+ const status = statusOf("chrome", { ...options, env: {} });
134
+ if (status.state !== "installed")
135
+ throw new BrowserNotInstalledError("chrome", status.label);
136
+ return downloader.executablePath(cacheDir, "chromedriver", PINNED.chrome.buildId);
137
+ }
130
138
  /** The folder the pinned build of `name` is, or would be, installed in */
131
139
  export function managedBuildDir(name, options = {}) {
132
140
  const { cacheDir, downloader } = store(options);
@@ -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):\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 --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";
2
2
  export interface Io {
3
3
  out: (text: string) => void;
4
4
  err: (text: string) => void;
package/dist/cli/main.js CHANGED
@@ -31,7 +31,7 @@ Options for run:
31
31
  Default: the config's use.headless, else headless when CI is set
32
32
  --port <n> Browser debugging port (default 9223)
33
33
 
34
- Options for record (also --env, --port, --json):
34
+ Options for record (also --env, --port, --json, --headless, --no-headless):
35
35
  --new <title> Add a test with this title (creates the spec file if needed)
36
36
  --test <name|index> Record into this test; not needed when the file has one
37
37
  --at <n> Insert from step n (default: after the last step)
@@ -114,6 +114,7 @@ export async function main(argv, io) {
114
114
  ...(values.new !== undefined && { create: values.new }),
115
115
  ...(at !== undefined && { at }),
116
116
  ...(values.url !== undefined && { url: values.url }),
117
+ ...(flags.headless !== undefined && { headless: flags.headless }),
117
118
  ...(flags.port !== undefined && { port: flags.port }),
118
119
  ...(io.signal && { signal: io.signal }),
119
120
  onStarted: () => {
@@ -132,13 +133,18 @@ export async function main(argv, io) {
132
133
  io.out(state === "paused" ? "Paused\n" : "Recording\n");
133
134
  }
134
135
  },
136
+ onMissingFile: (_file, text) => {
137
+ if (!values.json)
138
+ io.out(`${text}\n`);
139
+ },
135
140
  onEvent: (event) => {
136
141
  if (values.json)
137
142
  return void io.out(`${JSON.stringify(event)}\n`);
138
- if (event.op === "insert")
139
- io.out(` + ${stepToSource(event.step)}\n`);
140
- else
141
- io.out(` ~ ${stepToSource(event.step)}\n`);
143
+ if (event.op === "remove") {
144
+ count--;
145
+ return void io.out(` - step ${event.index + 1} (the click that opened the file picker)\n`);
146
+ }
147
+ io.out(` ${event.op === "insert" ? "+" : "~"} ${stepToSource(event.step)}\n`);
142
148
  if (event.op === "insert")
143
149
  count++;
144
150
  },
@@ -148,6 +154,15 @@ export async function main(argv, io) {
148
154
  if (result.unsetSecrets.length > 0) {
149
155
  io.out(`Set ${result.unsetSecrets.map((name) => `SAMURAI_SECRET_${name}`).join(", ")} (environment variable or .env.<environment>) before running it.\n`);
150
156
  }
157
+ if (result.missingFiles.length > 0) {
158
+ io.out(`Add ${result.missingFiles.map((f) => f.path).join(", ")} before running it (or use generatedFile).\n`);
159
+ }
160
+ const hasFiles = result.files.length + result.missingFiles.length > 0;
161
+ const folder = result.filesFolder === "" ? "." : result.filesFolder;
162
+ if (hasFiles && result.filesIgnored)
163
+ io.out(`${folder}/ is ignored by git; CI won't have these files.\n`);
164
+ if (hasFiles && result.firstFiles)
165
+ io.out(`Recorded files are stored in ${folder}/ and committed with your tests.\n`);
151
166
  }
152
167
  return 0;
153
168
  }
@@ -3,6 +3,7 @@ import type { BrowsingContext, ElementRectangle } from "../types/bidi-modules/br
3
3
  import { type HelperInstaller } from "../script/helpers.js";
4
4
  import { ElementHandle } from "../script/element-handle.js";
5
5
  import { type ElementScreenshotOptions } from "../browser/screenshot.js";
6
+ import { type FileInput } from "../browser/generated-files.js";
6
7
  import { type WaitForState } from "./element-state.js";
7
8
  import { type Selector, type TextOptions } from "./selector.js";
8
9
  export type { WaitForState } from "./element-state.js";
@@ -116,14 +117,25 @@ export default class Locator {
116
117
  */
117
118
  clickRect(rect: Pick<ElementRectangle, "x" | "y" | "width" | "height">): Promise<void>;
118
119
  /**
119
- * Sets the files of an `<input type=file>` (an empty list clears them); the browser fires `input` and `change`.
120
- * Paths are resolved against the working directory and must exist on the machine the browser runs on.
120
+ * Sets the files of an `<input type=file>`; `[]` clears it. Paths are relative to the project folder (absolute
121
+ * ones are used as they are); `generatedFile(…)` writes a file of a given size when the step runs.
121
122
  * Waits for the input to be attached and enabled; it need not be visible, file inputs are often hidden behind a label.
122
123
  * @example
123
- * await page.locator("input[@type='file']").setInputFiles("./fixtures/avatar.png");
124
- * await page.locator("input[@type='file']").setInputFiles([]);
124
+ * await page.getByLabel("Avatar").setInputFiles("files/avatar.png");
125
+ * await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
125
126
  */
126
- setInputFiles(files: string | string[], options?: {
127
+ setInputFiles(files: FileInput | FileInput[], options?: {
128
+ timeout?: number;
129
+ }): Promise<void>;
130
+ /**
131
+ * Drops files on the element, as dragging them from the desktop does: `dragenter`, `dragover` and `drop`
132
+ * with a `DataTransfer` holding the files. Takes what `setInputFiles` takes. The events are synthetic
133
+ * (`isTrusted` is false); a page that only accepts trusted drops ignores them.
134
+ * Waits for the element to be attached and visible.
135
+ * @example
136
+ * await page.getByTestId("dropzone").dropFiles(["files/a.png", "files/b.png"]);
137
+ */
138
+ dropFiles(files: FileInput | FileInput[], options?: {
127
139
  timeout?: number;
128
140
  }): Promise<void>;
129
141
  /**
@@ -3,7 +3,7 @@ import { callFunction, ScriptError } from "../script/call-function.js";
3
3
  import { CALL_PROBE, HELPER_SANDBOX, HELPERS_MISSING, } from "../script/helpers.js";
4
4
  import { ElementHandle } from "../script/element-handle.js";
5
5
  import { takeScreenshot, } from "../browser/screenshot.js";
6
- import { assertIsFile, resolveFiles } from "../browser/file-paths.js";
6
+ import { isGeneratedFile, resolveFileInputs, } from "../browser/generated-files.js";
7
7
  import { resolveTimeout, waitUntil, WaitTimeoutError, } from "../wait/wait-until.js";
8
8
  import { ActionTimeoutError } from "./action-timeout-error.js";
9
9
  import { allPass, DETACHED_STATE, evaluateChecks, parseElementState, PROBE_ELEMENT, } from "./element-state.js";
@@ -27,6 +27,31 @@ const FILE_INPUT_INFO = `(el) => ({
27
27
  multiple: Boolean(el.multiple),
28
28
  })`;
29
29
  const FILE_INPUT_CHECKS = ["attached", "enabled"];
30
+ const DROP_CHECKS = ["attached", "visible"];
31
+ /** The sandbox `dropFiles` builds its files in */
32
+ const DROP_SANDBOX = "samurai-drop";
33
+ const DROP_KEY = `globalThis[Symbol.for("samurai.drop")]`;
34
+ const DROP_INPUT = `() => {
35
+ const input = document.createElement("input");
36
+ input.type = "file";
37
+ input.multiple = true;
38
+ ${DROP_KEY} = input;
39
+ return input;
40
+ }`;
41
+ /** Fires dragenter, dragover and drop at the element's centre with the input's files (retyped where asked) */
42
+ const DROP_EVENTS = `(el, types) => {
43
+ const input = ${DROP_KEY};
44
+ delete ${DROP_KEY};
45
+ const data = new DataTransfer();
46
+ Array.from(input.files).forEach((file, i) => {
47
+ data.items.add(types[i] ? new File([file], file.name, { type: types[i], lastModified: file.lastModified }) : file);
48
+ });
49
+ const box = el.getBoundingClientRect();
50
+ const at = { clientX: box.left + box.width / 2, clientY: box.top + box.height / 2 };
51
+ for (const type of ["dragenter", "dragover", "drop"]) {
52
+ el.dispatchEvent(new DragEvent(type, Object.assign({ bubbles: true, cancelable: true, composed: true, dataTransfer: data }, at)));
53
+ }
54
+ }`;
30
55
  const SCREENSHOT_CHECKS = ["attached", "visible"];
31
56
  const CLICK_CHECKS = [
32
57
  "attached",
@@ -63,6 +88,16 @@ const NAMED_KEYS = {
63
88
  };
64
89
  /** Unicode code point WebDriver uses for the Backspace key */
65
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
+ }
66
101
  export default class Locator {
67
102
  /**
68
103
  * `selector` is an xpath, or a chain of selector steps that each search inside the previous step's matches.
@@ -191,7 +226,15 @@ export default class Locator {
191
226
  nodes = picked ? [picked] : [];
192
227
  }
193
228
  else {
194
- 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
+ }
195
238
  }
196
239
  if (nodes.length === 0)
197
240
  return [];
@@ -310,7 +353,8 @@ export default class Locator {
310
353
  }
311
354
  catch (err) {
312
355
  // The document changed between the search and the probe
313
- if (err instanceof BiDiError && err.code === "no such node")
356
+ if ((err instanceof BiDiError && err.code === "no such node") ||
357
+ isDocumentSwap(err))
314
358
  return DETACHED_STATE;
315
359
  throw err;
316
360
  }
@@ -410,17 +454,15 @@ export default class Locator {
410
454
  });
411
455
  }
412
456
  /**
413
- * Sets the files of an `<input type=file>` (an empty list clears them); the browser fires `input` and `change`.
414
- * Paths are resolved against the working directory and must exist on the machine the browser runs on.
457
+ * Sets the files of an `<input type=file>`; `[]` clears it. Paths are relative to the project folder (absolute
458
+ * ones are used as they are); `generatedFile(…)` writes a file of a given size when the step runs.
415
459
  * Waits for the input to be attached and enabled; it need not be visible, file inputs are often hidden behind a label.
416
460
  * @example
417
- * await page.locator("input[@type='file']").setInputFiles("./fixtures/avatar.png");
418
- * await page.locator("input[@type='file']").setInputFiles([]);
461
+ * await page.getByLabel("Avatar").setInputFiles("files/avatar.png");
462
+ * await page.getByLabel("Upload").setInputFiles(generatedFile({ name: "huge.pdf", size: "60 MB" }));
419
463
  */
420
464
  async setInputFiles(files, options) {
421
- const paths = resolveFiles(files);
422
- for (const file of paths)
423
- await assertIsFile(file, "setInputFiles");
465
+ const paths = await resolveFileInputs(files, "setInputFiles");
424
466
  await this.waitForActionable("setInputFiles", FILE_INPUT_CHECKS, { scroll: false, hitTest: false }, options?.timeout === undefined ? undefined : { timeout: options.timeout });
425
467
  const missing = () => new Error(`Failed to locate element ${this.selector}. Details: no element matches`);
426
468
  const el = await this.resolveOne();
@@ -450,6 +492,31 @@ export default class Locator {
450
492
  files: paths,
451
493
  });
452
494
  }
495
+ /**
496
+ * Drops files on the element, as dragging them from the desktop does: `dragenter`, `dragover` and `drop`
497
+ * with a `DataTransfer` holding the files. Takes what `setInputFiles` takes. The events are synthetic
498
+ * (`isTrusted` is false); a page that only accepts trusted drops ignores them.
499
+ * Waits for the element to be attached and visible.
500
+ * @example
501
+ * await page.getByTestId("dropzone").dropFiles(["files/a.png", "files/b.png"]);
502
+ */
503
+ async dropFiles(files, options) {
504
+ const list = Array.isArray(files) ? files : [files];
505
+ const paths = await resolveFileInputs(list, "dropFiles");
506
+ const types = list.map((file) => isGeneratedFile(file) ? (file.type ?? "") : "");
507
+ await this.waitForActionable("dropFiles", DROP_CHECKS, { scroll: true, hitTest: false }, options?.timeout === undefined ? undefined : { timeout: options.timeout });
508
+ const el = await this.resolveOne();
509
+ if (!el)
510
+ throw new Error(`Failed to locate element ${this.selector}. Details: no element matches`);
511
+ // A file input of our own, made in a sandbox and never attached: the page doesn't see it
512
+ const input = await this.call(DROP_INPUT, [], DROP_SANDBOX);
513
+ await this.biDiConnector.send("input.setFiles", {
514
+ context: this.contextId,
515
+ element: { sharedId: input.sharedId },
516
+ files: paths,
517
+ });
518
+ await this.call(DROP_EVENTS, [el, types], DROP_SANDBOX);
519
+ }
453
520
  /**
454
521
  * Takes a screenshot of just this element, after scrolling it into view and waiting until it is attached and
455
522
  * visible. Returns the image (also written to `options.path`).
@@ -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
  }