@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
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Samurai
2
2
 
3
- A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no WebDriver server and no Playwright or Puppeteer underneath.
3
+ A browser test framework written in TypeScript. It drives a real browser over the [WebDriver BiDi](https://www.w3.org/TR/webdriver-bidi/) protocol, with no Playwright or Puppeteer underneath. Firefox speaks BiDi itself; Chrome is driven through its own `chromedriver`, which the framework starts for you.
4
4
 
5
5
  [![CI](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml/badge.svg)](https://github.com/degrootsam/samurai-framework/actions/workflows/ci.yml)
6
6
 
@@ -20,16 +20,32 @@ test("Visitor can sign in", async ({ page, env, secrets }) => {
20
20
  - **Semantic locators.** By role, label, text, test id, CSS or XPath, with ordered fallbacks.
21
21
  - **Environments and secrets.** One test file runs against `dev`, `staging` or `production`; secrets come from the environment or a `.env.<name>` file and are masked in logs and reports.
22
22
  - **Network control.** Mock, block or modify requests; wait for requests, responses and network idle; read response bodies.
23
+ - **Flows.** Chain tests and groups into a graph with conditions, variables, waits and HTTP requests, and run it from the command line or CI (see [Flows](docs/flows.md)).
23
24
  - **Recorder building blocks.** Specs are parsed into steps and edited back with minimal diffs, and an engine records what a person does in the browser. A toolbar in the recorded page adds assertions, variables and secrets, waits, screenshots and pause (see [Steps and the recorder](docs/recorder.md)). The Electron app that ties them together lives in a separate repository (see the [roadmap](docs/roadmap.md)).
24
25
 
25
26
  > **Status:** early. Firefox only. See [what is still open](docs/roadmap.md).
26
27
 
27
28
  ## Getting started
28
29
 
30
+ ### In your own project
31
+
32
+ The package is published on npm as [`@itmetsam/samurai-framework`](https://www.npmjs.com/package/@itmetsam/samurai-framework). In an empty folder:
33
+
34
+ ```sh
35
+ npx -p @itmetsam/samurai-framework samurai init # config, an example test, .env.example, .gitignore
36
+ npm install
37
+ npx samurai browsers install firefox # once per machine
38
+ npx samurai run
39
+ ```
40
+
41
+ Specs and the config then import from `@itmetsam/samurai-framework` instead of the relative paths used below. See [Command line](docs/cli.md).
42
+
43
+ The rest of this section works in a checkout of this repository.
44
+
29
45
  ### 1. Requirements
30
46
 
31
- - [Node.js](https://nodejs.org) 26 or newer and [Bun](https://bun.sh) 1.3 or newer (CI uses these versions)
32
- - Firefox, downloaded by the framework: run `npx samurai browsers install firefox` once in a project (`bun run dev browsers install firefox` in this checkout; see [Which browser runs](docs/pages-and-browsers.md#which-browser-runs)). The Firefox installed on the machine is not used.
47
+ - [Node.js](https://nodejs.org) 24 or newer (`engines` in `package.json`; CI uses 26) and, to work on the framework, [Bun](https://bun.sh) 1.3 or newer
48
+ - Firefox, downloaded by the framework: run `bun run dev browsers install firefox` once in this checkout (`npx samurai browsers install firefox` in a project; see [Which browser runs](docs/pages-and-browsers.md#which-browser-runs)). The Firefox installed on the machine is not used.
33
49
 
34
50
  ### 2. Install
35
51
 
@@ -86,9 +102,9 @@ bun run dev --env production # another environment (or SAMURAI_ENV=production)
86
102
  bun run dev --timeout 60000 # per-test timeout in ms; also --expect-timeout
87
103
  ```
88
104
 
89
- Once the package is installed in a project, `samurai run` does the same with a readable summary and a proper exit code, and `samurai init` scaffolds a new project (see [Command line](docs/cli.md)).
105
+ `bun run dev` is `samurai run` straight from the sources, so it takes the same options (`--grep`, `--file`, `--group`, `--headless`, `--json`, …) and the other commands too (`bun run dev list`, `bun run dev browsers install firefox`). See [Command line](docs/cli.md).
90
106
 
91
- A browser window opens for each test and closes when the test ends. The result is written to `result/report.json` (status, duration and error per test); nothing summarises it on the console yet. The process exits with code 0 when every test passed and 1 otherwise (`--headless` hides the window; on CI it is the default, see [Configuration](docs/configuration.md#headless)). Framework logs go to the console and `logs/`.
107
+ A browser window opens for each test and closes when the test ends (`--headless` hides it; on CI it is the default, see [Configuration](docs/configuration.md#headless)). Each test's result is printed as it finishes, followed by a summary, and the full report is written to `result/report.json`. The process exits with code 0 when every test passed, 1 otherwise and 2 for a usage or configuration error. Framework logs go to stderr and `logs/`.
92
108
 
93
109
  > `src/tests/index.spec.ts` is an example that submits a real contact form. Delete it or replace it before running everything.
94
110
 
@@ -100,6 +116,7 @@ A project can also keep flows in `flows/*.flow.json` (edited in the Samurai app)
100
116
 
101
117
  ```sh
102
118
  cp .env.example .env.staging
119
+ # then add a line per secret, e.g.
103
120
  # SAMURAI_SECRET_TEST_PASSWORD=...
104
121
  ```
105
122
 
@@ -118,19 +135,21 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
118
135
  | [Environments and secrets](docs/environments-and-secrets.md) | Per-environment settings, variables, secrets and masking |
119
136
  | [Command line](docs/cli.md) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
120
137
  | [Flows](docs/flows.md) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
138
+ | [Writing a step](docs/steps.md) | `defineStep`: flow steps of your own, the built-in steps |
121
139
  | [Running tests and reports](docs/running-and-reports.md) | What a run does, `report.json`, page logs, timeouts |
122
140
  | [Steps and the recorder](docs/recorder.md) | The step codec, the recording engine and the recorder toolbar |
123
141
  | [Embedding the framework](docs/embedding.md) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
124
142
  | [Contributing](docs/contributing.md) | Project layout, running the framework's own tests, conventions |
125
143
  | [Roadmap](docs/roadmap.md) | What is built and what is still open |
126
144
 
127
- Design notes for every feature live in [`docs/superpowers/specs`](docs/superpowers/specs).
145
+ The [documentation index](docs/README.md) lists every guide and design note by topic. Design notes for every feature live in [`docs/superpowers/specs`](docs/superpowers/specs).
128
146
 
129
147
  ## Scripts
130
148
 
131
- | Command | Does |
132
- | ---------------------- | ----------------------------------------------- |
133
- | `bun run dev` | Runs the test suite (`tsx src/index.ts`) |
134
- | `bun run test` | Unit tests of the framework (no browser needed) |
135
- | `bun run test:browser` | Browser tests of the framework (needs Firefox) |
136
- | `bun run build` | Typechecks and compiles to `dist/` |
149
+ | Command | Does |
150
+ | ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
151
+ | `bun run dev` | `samurai` from the sources (`tsx src/index.ts`) |
152
+ | `bun run test` | Unit tests of the framework (no browser needed) |
153
+ | `bun run test:browser` | Browser tests of the framework (Firefox; `SAMURAI_TEST_BROWSER=chrome` or `bun run test:browser:chrome` for Chrome) |
154
+ | `bun run test:cli` | Tests of the `samurai` command (build first) |
155
+ | `bun run build` | Typechecks and compiles to `dist/` |
package/dist/api.d.ts CHANGED
@@ -3,3 +3,4 @@ export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
4
  export { defineStep, type StepDefinition, type StepResult, type StepRunInput, } from "./flows/steps/index.js";
5
5
  export type { TestFixtures } from "./types/test.js";
6
+ export { generatedFile, type GeneratedFile, type FileInput, } from "./browser/generated-files.js";
package/dist/api.js CHANGED
@@ -2,3 +2,4 @@ export { test, describe } from "./runner/registry.js";
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
4
  export { defineStep, } from "./flows/steps/index.js";
5
+ export { generatedFile, } from "./browser/generated-files.js";
@@ -4,7 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import logger from "../logger/index.js";
5
5
  const supportedBrowsers = ["firefox"];
6
6
  const macBrowserPaths = {
7
- // chrome: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
7
+ chrome: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
8
8
  firefox: "/Applications/Firefox.app/Contents/MacOS/Firefox",
9
9
  };
10
10
  /** @deprecated Launches use the builds SAMURAI downloads (`samurai browsers install`); removed in a later major */
@@ -82,7 +82,7 @@ function findBrowserMac(browserName) {
82
82
  return checkPathExists(macBrowserPaths[browserName]);
83
83
  }
84
84
  const linuxBinaryNames = {
85
- // chrome: "google-chrome",
85
+ chrome: "google-chrome",
86
86
  firefox: "firefox",
87
87
  };
88
88
  function findBrowserLinux(browserName) {
@@ -1,23 +1,15 @@
1
- import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
1
  import type { SupportedBrowser } from "../types/browser.js";
3
2
  import { BiDiConnector } from "../transport/bidi-connection.js";
4
3
  import Page from "./page.js";
4
+ import { SESSION_CAPABILITIES } from "./session-capabilities.js";
5
+ import { type BrowserProcess } from "./chrome-launch.js";
5
6
  import { BrowserContext } from "./browser-context.js";
6
7
  import type { UserPromptHandler } from "../types/bidi-modules/session.js";
7
8
  import { type EmulationOptions } from "./emulation.js";
9
+ import { ChromeDownloadFolders } from "./download-folders.js";
8
10
  import type { BiDiCommands } from "../types/bidi.js";
9
11
  import type { CookieFilter, PartialCookie, PartitionDescriptor } from "../types/bidi-modules/storage.js";
10
- /**
11
- * Sent with session.new. With "ignore" the browser leaves a JavaScript dialog open and reports it
12
- * (`browsingContext.userPromptOpened`), so the framework decides how to answer instead of the browser.
13
- */
14
- export declare const SESSION_CAPABILITIES: {
15
- alwaysMatch: {
16
- unhandledPromptBehavior: {
17
- default: string;
18
- };
19
- };
20
- };
12
+ export { SESSION_CAPABILITIES };
21
13
  /** session.new got no reply in time */
22
14
  export declare class BrowserStartTimeoutError extends Error {
23
15
  constructor(label: string, timeout: number);
@@ -39,7 +31,7 @@ export declare const browserLaunchEnv: (browserName: SupportedBrowser, appDataPa
39
31
  export interface BrowserLaunchOptions {
40
32
  /** Which port to use for the WebDeveloper tools */
41
33
  port: number;
42
- /** Firefox only */
34
+ /** The browser profile folder: user.js goes in it for Firefox, Chrome's user data for Chrome */
43
35
  profileDir: string;
44
36
  /** Start the browser in headless mode or in UI mode */
45
37
  headless: boolean;
@@ -60,10 +52,12 @@ export declare class Browser {
60
52
  /** Where downloads are saved; undefined when the browser could not be told (it then uses its own folder) */
61
53
  readonly downloadsDir: string | undefined;
62
54
  private exitedPromise;
63
- constructor({ browserProc, biDiConnector, downloadsDir, }: {
64
- browserProc: ChildProcessWithoutNullStreams;
55
+ private downloadFolders;
56
+ constructor({ browserProc, biDiConnector, downloadsDir, downloadFolders, }: {
57
+ browserProc: BrowserProcess;
65
58
  biDiConnector: BiDiConnector;
66
59
  downloadsDir?: string | undefined;
60
+ downloadFolders?: ChromeDownloadFolders | undefined;
67
61
  });
68
62
  /** Resolves when the browser process has exited, whether `close()` or a person closed it */
69
63
  get exited(): Promise<void>;
@@ -73,6 +67,8 @@ export declare class Browser {
73
67
  browser: Browser;
74
68
  page: Page;
75
69
  }>;
70
+ /** Sets up a browser whose BiDi session is open: downloads folder, the first page and what it tracks */
71
+ private static attach;
76
72
  /** Closes the browser via BiDi browser.close, then makes sure the process has exited */
77
73
  close(): Promise<void>;
78
74
  /** Alias of close() */
@@ -6,18 +6,15 @@ import path from "node:path";
6
6
  import { BiDiConnector, DEFAULT_COMMAND_TIMEOUT, } from "../transport/bidi-connection.js";
7
7
  import { BiDiError } from "../transport/bidi-error.js";
8
8
  import Page from "./page.js";
9
+ import { SESSION_CAPABILITIES } from "./session-capabilities.js";
10
+ import { launchChrome } from "./chrome-launch.js";
9
11
  import { BrowserContext } from "./browser-context.js";
10
12
  import { validateEmulation } from "./emulation.js";
11
13
  import { prepareDownloadsDir, removeIfEmpty } from "./downloads-dir.js";
14
+ import { ChromeDownloadFolders } from "./download-folders.js";
12
15
  import logger from "../logger/index.js";
13
16
  import { browsersDir, projectDir, readConfig } from "../config/config.js";
14
- /**
15
- * Sent with session.new. With "ignore" the browser leaves a JavaScript dialog open and reports it
16
- * (`browsingContext.userPromptOpened`), so the framework decides how to answer instead of the browser.
17
- */
18
- export const SESSION_CAPABILITIES = {
19
- alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } },
20
- };
17
+ export { SESSION_CAPABILITIES };
21
18
  /** session.new waits at least this long (ms): the first start after an install can take a while (macOS checks the new app) */
22
19
  const SESSION_START_TIMEOUT = 120_000;
23
20
  /** session.new got no reply in time */
@@ -43,7 +40,8 @@ export async function startSession(connector, label, commandTimeout) {
43
40
  const BROWSER_CLOSE_GRACE = 2000;
44
41
  const browserProfilePath = (browserName) => path.join(browsersDir(), "profiles", browserName, "user.js");
45
42
  const baseBrowserLaunchFlags = {
46
- // chrome: [],
43
+ // Chrome is started by its chromedriver: see chrome-launch.ts
44
+ chrome: [],
47
45
  firefox: ["--no-sandbox", "--no-remote"],
48
46
  };
49
47
  /** Read when a browser launches, since the profile folder depends on the active project */
@@ -52,10 +50,6 @@ const defaultLaunchOptions = (browserName) => ({
52
50
  profileDir: path.dirname(browserProfilePath(browserName)),
53
51
  headless: true,
54
52
  });
55
- const regexPrefix = {
56
- firefox: /WebDriver\sBiDi\slistening\son\s/,
57
- // chrome: /DevTools\slistening\son/,
58
- };
59
53
  export const browserLaunchFlag = (browserName, launchOptions) => {
60
54
  const defLaunchOpts = defaultLaunchOptions(browserName);
61
55
  const port = launchOptions.port || defLaunchOpts.port;
@@ -67,9 +61,8 @@ export const browserLaunchFlag = (browserName, launchOptions) => {
67
61
  }
68
62
  baseLaunchFlags.push(`--remote-debugging-port=${port}`);
69
63
  switch (browserName) {
70
- // case "chrome":
71
- // baseLaunchFlags.push(`--user-data-dir=${profileDir}`);
72
- // break;
64
+ case "chrome":
65
+ break;
73
66
  case "firefox":
74
67
  baseLaunchFlags.push("--profile", profileDir);
75
68
  // The home page (about:home) is privileged: BiDi refuses commands such as setViewport on it
@@ -97,6 +90,8 @@ export const browserLaunchEnv = (browserName, appDataPath = browserAppDataPath(b
97
90
  mkdirSync(appDataPath, { recursive: true });
98
91
  logger.debug("Using app data dir for %s: %s", browserName, appDataPath);
99
92
  switch (browserName) {
93
+ case "chrome":
94
+ return {};
100
95
  case "firefox":
101
96
  // The project profile may have been opened by a newer Firefox (the system one, before 0.6);
102
97
  // without this Firefox refuses to start on it
@@ -104,7 +99,8 @@ export const browserLaunchEnv = (browserName, appDataPath = browserAppDataPath(b
104
99
  }
105
100
  };
106
101
  const browserProfiles = {
107
- // chrome: "",
102
+ // Chrome needs no prefs file; its profile folder is created at launch
103
+ chrome: "",
108
104
  firefox: `
109
105
  user_pref("devtools.debugger.remote-enabled", true);
110
106
  user_pref("devtools.debugger.prompt-connection", false);
@@ -116,17 +112,12 @@ const browserProfiles = {
116
112
  `,
117
113
  };
118
114
  // Firefox logs "WebDriver BiDi listening on ws://<host>:<port>" without the endpoint path
119
- const browserWsPath = {
120
- firefox: "/session",
121
- };
122
- const browserWsRegex = {
123
- firefox: /ws:\/\/(\d){1,3}.(\d){1,3}.(\d){1,3}.(\d){1,3}:(\d){4}/g,
124
- // chrome:
125
- // /ws:\/\/(\d){1,3}.(\d){1,3}.(\d){1,3}.(\d){1,3}:(\d){4}\/devtools\/browser\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
126
- };
115
+ const FIREFOX_WS_PATH = "/session";
116
+ const FIREFOX_WS_REGEX = /ws:\/\/(\d){1,3}.(\d){1,3}.(\d){1,3}.(\d){1,3}:(\d){4}/g;
127
117
  export class Browser {
128
- constructor({ browserProc, biDiConnector, downloadsDir, }) {
118
+ constructor({ browserProc, biDiConnector, downloadsDir, downloadFolders, }) {
129
119
  this.userContexts = new Map();
120
+ this.downloadFolders = downloadFolders;
130
121
  this.browserProc = browserProc;
131
122
  this.biDiConnector = biDiConnector;
132
123
  this.downloadsDir = downloadsDir;
@@ -151,9 +142,29 @@ export class Browser {
151
142
  signal) {
152
143
  signal?.throwIfAborted();
153
144
  logger.verbose("Trying to launch browser %s", browserName);
154
- const { executablePath: browserLocation, label } = await projectBrowser(browserName);
145
+ const resolved = await projectBrowser(browserName);
146
+ const { executablePath: browserLocation, label } = resolved;
155
147
  logger.debug("Found browser install location at: %s", browserLocation);
156
148
  ensureBrowserProfile(browserName);
149
+ if (browserName === "chrome") {
150
+ const commandTimeout = await readCommandTimeout();
151
+ const { proc, connector } = await launchChrome({
152
+ executablePath: browserLocation,
153
+ driverPath: resolved.driverPath,
154
+ profileDir: launchOptions.profileDir ?? defaultLaunchOptions("chrome").profileDir,
155
+ headless: launchOptions.headless ?? defaultLaunchOptions("chrome").headless,
156
+ commandTimeout,
157
+ signal,
158
+ });
159
+ try {
160
+ // The chromedriver session is the BiDi session: there is no session.new to send
161
+ return await Browser.attach(proc, connector, browserName);
162
+ }
163
+ catch (err) {
164
+ abandonLaunch(proc, connector);
165
+ throw err;
166
+ }
167
+ }
157
168
  logger.verbose("Trying to spawn %s process", browserName);
158
169
  const browserProc = spawn(browserLocation, browserLaunchFlag(browserName, launchOptions), { env: { ...process.env, ...browserLaunchEnv(browserName) } });
159
170
  return new Promise((resolve, reject) => {
@@ -176,7 +187,7 @@ export class Browser {
176
187
  });
177
188
  browserProc.stderr.on("data", async (data) => {
178
189
  logger.debug(`${browserName} stderr: ${data}`);
179
- const urlMatch = String(data).match(browserWsRegex[browserName]);
190
+ const urlMatch = String(data).match(FIREFOX_WS_REGEX);
180
191
  logger.debug("Browser announced its endpoint", { urlMatch });
181
192
  if (!Array.isArray(urlMatch) || urlMatch.length === 0) {
182
193
  return;
@@ -185,7 +196,7 @@ export class Browser {
185
196
  // instead of an unhandled rejection from this async listener
186
197
  let biDiConnector;
187
198
  try {
188
- let url = urlMatch[0] + browserWsPath[browserName];
199
+ let url = urlMatch[0] + FIREFOX_WS_PATH;
189
200
  logger.verbose("Websocket URL match: %s", url);
190
201
  const commandTimeout = await readCommandTimeout();
191
202
  biDiConnector = await BiDiConnector.connect(url, { commandTimeout });
@@ -194,38 +205,10 @@ export class Browser {
194
205
  logger.verbose("Session status", status);
195
206
  await startSession(biDiConnector, label, commandTimeout);
196
207
  logger.verbose("Session started successfully");
197
- const downloadsDir = await prepareDownloadsDir(biDiConnector, path.resolve(projectDir(), (await readOptionalConfig("downloadsDir")) ?? "result/downloads"));
198
- const browser = new Browser({
199
- browserProc,
200
- biDiConnector,
201
- downloadsDir,
202
- });
203
- logger.verbose("Requesting current browser tree");
204
- const browserTree = await biDiConnector.send("browsingContext.getTree", {});
205
- logger.verbose("Received current browser tree");
206
- logger.debug("Browser tree: ", { browserTree });
207
- const pageContext = browserTree.contexts[0];
208
- let page = undefined;
209
- if (pageContext) {
210
- page = new Page(biDiConnector, pageContext.context);
211
- browser.defaultContext.register(page);
212
- }
213
- else {
214
- page = await browser.newPage({ type: "tab" });
215
- }
216
- await page.applyDefaultViewport();
217
- // Requests made before tracking starts are invisible to it, and a dialog nobody watches would hang the page
218
- await page.startNetworkTracking();
219
- await page.startDialogHandling();
220
- await page.startLogging();
221
- await page.startDownloadTracking();
222
- await page.startFileChooserTracking();
208
+ const launched = await Browser.attach(browserProc, biDiConnector, browserName);
223
209
  browserProc.off("close", onUnexpectedClose);
224
210
  signal?.removeEventListener("abort", onAbort);
225
- resolve({
226
- browser,
227
- page,
228
- });
211
+ resolve(launched);
229
212
  }
230
213
  catch (err) {
231
214
  browserProc.off("close", onUnexpectedClose);
@@ -236,8 +219,46 @@ export class Browser {
236
219
  });
237
220
  });
238
221
  }
222
+ /** Sets up a browser whose BiDi session is open: downloads folder, the first page and what it tracks */
223
+ static async attach(browserProc, biDiConnector, browserName) {
224
+ biDiConnector.browserName = browserName;
225
+ const downloadsDir = await prepareDownloadsDir(biDiConnector, path.resolve(projectDir(), (await readOptionalConfig("downloadsDir")) ?? "result/downloads"));
226
+ // Chrome replaces a file that has the name of a download; this keeps every download
227
+ const downloadFolders = browserName === "chrome" && downloadsDir
228
+ ? new ChromeDownloadFolders(biDiConnector, downloadsDir)
229
+ : undefined;
230
+ await downloadFolders?.start();
231
+ const browser = new Browser({
232
+ browserProc,
233
+ biDiConnector,
234
+ downloadsDir,
235
+ downloadFolders,
236
+ });
237
+ logger.verbose("Requesting current browser tree");
238
+ const browserTree = await biDiConnector.send("browsingContext.getTree", {});
239
+ logger.verbose("Received current browser tree");
240
+ logger.debug("Browser tree: ", { browserTree });
241
+ const pageContext = browserTree.contexts[0];
242
+ let page = undefined;
243
+ if (pageContext) {
244
+ page = new Page(biDiConnector, pageContext.context);
245
+ browser.defaultContext.register(page);
246
+ }
247
+ else {
248
+ page = await browser.newPage({ type: "tab" });
249
+ }
250
+ await page.applyDefaultViewport();
251
+ // Requests made before tracking starts are invisible to it, and a dialog nobody watches would hang the page
252
+ await page.startNetworkTracking();
253
+ await page.startDialogHandling();
254
+ await page.startLogging();
255
+ await page.startDownloadTracking();
256
+ await page.startFileChooserTracking();
257
+ return { browser, page };
258
+ }
239
259
  /** Closes the browser via BiDi browser.close, then makes sure the process has exited */
240
260
  async close() {
261
+ await this.downloadFolders?.dispose();
241
262
  if (this.browserProc.exitCode !== null || this.browserProc.signalCode) {
242
263
  this.biDiConnector.kill();
243
264
  removeIfEmpty(this.downloadsDir);
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ import EventEmitter from "node:events";
3
+ import { BiDiConnector } from "../transport/bidi-connection.js";
4
+ /** What `Browser` needs of the thing it launched: the browser process, or for Chrome the chromedriver in front of it */
5
+ export interface BrowserProcess {
6
+ readonly exitCode: number | null;
7
+ readonly signalCode: NodeJS.Signals | null;
8
+ once(event: "exit", listener: (...args: any[]) => void): unknown;
9
+ kill(signal?: NodeJS.Signals): boolean;
10
+ }
11
+ export interface ChromeLaunch {
12
+ executablePath: string;
13
+ driverPath: string;
14
+ profileDir: string;
15
+ headless: boolean;
16
+ commandTimeout: number | undefined;
17
+ signal?: AbortSignal | undefined;
18
+ }
19
+ export declare const chromeFlags: ({ headless, profileDir, }: Pick<ChromeLaunch, "headless" | "profileDir">) => string[];
20
+ /** The capabilities chromedriver gets: a classic session that also opens a BiDi socket */
21
+ export declare const chromeCapabilities: (launch: ChromeLaunch) => {
22
+ capabilities: {
23
+ alwaysMatch: {
24
+ "goog:chromeOptions": {
25
+ binary: string;
26
+ args: string[];
27
+ };
28
+ unhandledPromptBehavior: {
29
+ default: string;
30
+ };
31
+ browserName: string;
32
+ webSocketUrl: boolean;
33
+ };
34
+ };
35
+ };
36
+ /**
37
+ * Chrome speaks BiDi through its chromedriver: this starts one, asks it for a session with `webSocketUrl`, and
38
+ * connects to the socket it hands back. The session exists when this resolves, so there is no `session.new`.
39
+ * The returned process handle ends when the chromedriver does or the socket drops (a person closed the window).
40
+ */
41
+ export declare function launchChrome(launch: ChromeLaunch): Promise<{
42
+ proc: BrowserProcess;
43
+ connector: BiDiConnector;
44
+ }>;
45
+ /** chromedriver as the process of a Chrome launch: ending it ends Chrome, and so does deleting the session */
46
+ export declare class ChromeProcess extends EventEmitter implements BrowserProcess {
47
+ private driver;
48
+ private deleteSession;
49
+ session: string | undefined;
50
+ private done;
51
+ constructor(driver: Pick<ReturnType<typeof spawn>, "once" | "kill" | "exitCode" | "signalCode">, deleteSession?: (url: string) => Promise<unknown>);
52
+ get exitCode(): number | null;
53
+ get signalCode(): NodeJS.Signals | null;
54
+ /** The socket or the chromedriver is gone: the launch is over */
55
+ ended(): void;
56
+ kill(signal?: NodeJS.Signals): boolean;
57
+ }
@@ -0,0 +1,127 @@
1
+ import { spawn } from "node:child_process";
2
+ import EventEmitter from "node:events";
3
+ import { mkdirSync } from "node:fs";
4
+ import logger from "../logger/index.js";
5
+ import { BiDiConnector } from "../transport/bidi-connection.js";
6
+ import { SESSION_CAPABILITIES } from "./session-capabilities.js";
7
+ export const chromeFlags = ({ headless, profileDir, }) => [
8
+ "--no-sandbox",
9
+ "--no-first-run",
10
+ "--no-default-browser-check",
11
+ "--disable-search-engine-choice-screen",
12
+ `--user-data-dir=${profileDir}`,
13
+ // Headless Chrome draws 15 px classic scrollbars inside the viewport; Firefox headless overlays them
14
+ ...(headless ? ["--headless=new", "--hide-scrollbars"] : []),
15
+ ];
16
+ /** The capabilities chromedriver gets: a classic session that also opens a BiDi socket */
17
+ export const chromeCapabilities = (launch) => ({
18
+ capabilities: {
19
+ alwaysMatch: {
20
+ browserName: "chrome",
21
+ webSocketUrl: true,
22
+ ...SESSION_CAPABILITIES.alwaysMatch,
23
+ "goog:chromeOptions": {
24
+ binary: launch.executablePath,
25
+ args: chromeFlags(launch),
26
+ },
27
+ },
28
+ },
29
+ });
30
+ /**
31
+ * Chrome speaks BiDi through its chromedriver: this starts one, asks it for a session with `webSocketUrl`, and
32
+ * connects to the socket it hands back. The session exists when this resolves, so there is no `session.new`.
33
+ * The returned process handle ends when the chromedriver does or the socket drops (a person closed the window).
34
+ */
35
+ export async function launchChrome(launch) {
36
+ mkdirSync(launch.profileDir, { recursive: true });
37
+ launch.signal?.throwIfAborted();
38
+ const driver = spawn(launch.driverPath, ["--port=0"], {
39
+ stdio: ["ignore", "pipe", "pipe"],
40
+ });
41
+ const proc = new ChromeProcess(driver);
42
+ const onAbort = () => proc.kill();
43
+ launch.signal?.addEventListener("abort", onAbort, { once: true });
44
+ let connector;
45
+ try {
46
+ const port = await driverPort(driver);
47
+ const base = `http://127.0.0.1:${port}`;
48
+ const res = await fetch(`${base}/session`, {
49
+ method: "POST",
50
+ body: JSON.stringify(chromeCapabilities(launch)),
51
+ signal: launch.signal ?? null,
52
+ });
53
+ const body = (await res.json());
54
+ const { sessionId, capabilities } = body.value;
55
+ if (!res.ok || !sessionId || !capabilities?.webSocketUrl)
56
+ throw new Error(`chromedriver could not start Chrome: ${body.value.message ?? res.statusText}`);
57
+ proc.session = `${base}/session/${sessionId}`;
58
+ logger.verbose("Chrome BiDi socket: %s", capabilities.webSocketUrl);
59
+ connector = await BiDiConnector.connect(capabilities.webSocketUrl, {
60
+ ...(launch.commandTimeout !== undefined && {
61
+ commandTimeout: launch.commandTimeout,
62
+ }),
63
+ });
64
+ connector.onClosed(() => proc.ended());
65
+ return { proc, connector };
66
+ }
67
+ catch (err) {
68
+ connector?.kill();
69
+ proc.kill();
70
+ if (launch.signal?.aborted)
71
+ throw launch.signal.reason;
72
+ throw err;
73
+ }
74
+ finally {
75
+ launch.signal?.removeEventListener("abort", onAbort);
76
+ }
77
+ }
78
+ function driverPort(driver) {
79
+ return new Promise((resolve, reject) => {
80
+ const onData = (data) => {
81
+ logger.debug(`chromedriver: ${data}`);
82
+ const match = String(data).match(/started successfully on port (\d+)/);
83
+ if (match)
84
+ resolve(Number(match[1]));
85
+ };
86
+ driver.stdout?.on("data", onData);
87
+ driver.stderr?.on("data", onData);
88
+ driver.once("error", reject);
89
+ driver.once("exit", (code) => reject(new Error(`chromedriver exited with code ${code} before it was ready`)));
90
+ });
91
+ }
92
+ /** chromedriver as the process of a Chrome launch: ending it ends Chrome, and so does deleting the session */
93
+ export class ChromeProcess extends EventEmitter {
94
+ constructor(driver, deleteSession = (url) => fetch(url, { method: "DELETE" })) {
95
+ super();
96
+ this.driver = driver;
97
+ this.deleteSession = deleteSession;
98
+ this.done = false;
99
+ driver.once("exit", () => this.ended());
100
+ }
101
+ get exitCode() {
102
+ return this.done ? (this.driver.exitCode ?? 0) : null;
103
+ }
104
+ get signalCode() {
105
+ return this.driver.signalCode;
106
+ }
107
+ /** The socket or the chromedriver is gone: the launch is over */
108
+ ended() {
109
+ if (this.done)
110
+ return;
111
+ this.done = true;
112
+ this.emit("exit");
113
+ this.driver.kill();
114
+ }
115
+ kill(signal) {
116
+ // Deleting the session makes chromedriver quit Chrome; killing chromedriver alone could leave it running
117
+ const session = this.session;
118
+ this.session = undefined;
119
+ if (session) {
120
+ this.deleteSession(session)
121
+ .catch(() => undefined)
122
+ .finally(() => this.driver.kill(signal));
123
+ return true;
124
+ }
125
+ return this.driver.kill(signal);
126
+ }
127
+ }
@@ -0,0 +1,27 @@
1
+ import type { BiDiConnector } from "../transport/bidi-connection.js";
2
+ /** `report.csv` becomes `report(1).csv`, `report(2).csv`, … (the names Firefox gives) until a name is free in `dir` */
3
+ export declare function uniqueName(dir: string, name: string): string;
4
+ /**
5
+ * Chrome saves a download under its suggested name and replaces a file that has it already, where Firefox picks a
6
+ * free name. This gives every download a folder of its own while it is written (the next folder is set when a download
7
+ * begins) and moves the finished file into the downloads folder under a free name, so no download replaces another.
8
+ * Must be started before the download trackers: it rewrites `filepath` of `downloadEnd` for them.
9
+ */
10
+ export declare class ChromeDownloadFolders {
11
+ private connector;
12
+ private dir;
13
+ private subscription;
14
+ private staging;
15
+ private counter;
16
+ private disposed;
17
+ constructor(connector: BiDiConnector, dir: string);
18
+ start(): Promise<void>;
19
+ /** Idempotent */
20
+ dispose(): Promise<void>;
21
+ private stagingRoot;
22
+ /** Points the browser at a new, empty folder for the download after this one */
23
+ private rotate;
24
+ private removeIfEmpty;
25
+ private onBegin;
26
+ private onEnd;
27
+ }