@itmetsam/samurai-framework 0.8.0 → 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 (41) hide show
  1. package/README.md +8 -8
  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 +57 -0
  6. package/dist/browser/chrome-launch.js +127 -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/locator/locator.js +21 -2
  27. package/dist/network/data-collector.d.ts +1 -0
  28. package/dist/network/data-collector.js +23 -5
  29. package/dist/network/network-tracker.d.ts +8 -1
  30. package/dist/network/network-tracker.js +42 -3
  31. package/dist/network/router.d.ts +5 -0
  32. package/dist/network/router.js +33 -11
  33. package/dist/recorder/session.js +4 -3
  34. package/dist/recorder/toolbar/controller.d.ts +6 -5
  35. package/dist/recorder/toolbar/controller.js +39 -7
  36. package/dist/script/preload.js +65 -0
  37. package/dist/transport/bidi-connection.d.ts +6 -0
  38. package/dist/transport/bidi-connection.js +13 -0
  39. package/dist/types/browser.d.ts +1 -1
  40. package/dist/types/config.d.ts +1 -1
  41. package/package.json +3 -2
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
 
@@ -146,10 +146,10 @@ The [documentation index](docs/README.md) lists every guide and design note by t
146
146
 
147
147
  ## Scripts
148
148
 
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 (needs Firefox) |
154
- | `bun run test:cli` | Tests of the `samurai` command (build first) |
155
- | `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/` |
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import logger from "../logger/index.js";
4
+ /** `report.csv` becomes `report(1).csv`, `report(2).csv`, … (the names Firefox gives) until a name is free in `dir` */
5
+ export function uniqueName(dir, name) {
6
+ if (!existsSync(path.join(dir, name)))
7
+ return name;
8
+ const { name: stem, ext } = path.parse(name);
9
+ for (let n = 1;; n++) {
10
+ const candidate = `${stem}(${n})${ext}`;
11
+ if (!existsSync(path.join(dir, candidate)))
12
+ return candidate;
13
+ }
14
+ }
15
+ /**
16
+ * Chrome saves a download under its suggested name and replaces a file that has it already, where Firefox picks a
17
+ * free name. This gives every download a folder of its own while it is written (the next folder is set when a download
18
+ * begins) and moves the finished file into the downloads folder under a free name, so no download replaces another.
19
+ * Must be started before the download trackers: it rewrites `filepath` of `downloadEnd` for them.
20
+ */
21
+ export class ChromeDownloadFolders {
22
+ constructor(connector, dir) {
23
+ this.connector = connector;
24
+ this.dir = dir;
25
+ this.counter = 0;
26
+ this.disposed = false;
27
+ this.onBegin = (_params) => {
28
+ if (this.disposed)
29
+ return;
30
+ this.rotate().catch((err) => logger.error("Could not set a new download folder", { err }));
31
+ };
32
+ this.onEnd = (params) => {
33
+ if (this.disposed || params.status !== "complete")
34
+ return;
35
+ try {
36
+ const from = params.filepath;
37
+ const to = path.join(this.dir, uniqueName(this.dir, path.basename(from)));
38
+ renameSync(from, to);
39
+ params.filepath = to;
40
+ this.removeIfEmpty(path.dirname(from));
41
+ }
42
+ catch (err) {
43
+ logger.error("Could not move a finished download", { err });
44
+ }
45
+ };
46
+ }
47
+ async start() {
48
+ this.connector.onEvent("browsingContext.downloadWillBegin", this.onBegin);
49
+ this.connector.onEvent("browsingContext.downloadEnd", this.onEnd);
50
+ try {
51
+ this.subscription = await this.connector.subscribe([
52
+ "browsingContext.downloadWillBegin",
53
+ "browsingContext.downloadEnd",
54
+ ]);
55
+ await this.rotate();
56
+ }
57
+ catch (err) {
58
+ await this.dispose();
59
+ throw err;
60
+ }
61
+ }
62
+ /** Idempotent */
63
+ async dispose() {
64
+ if (this.disposed)
65
+ return;
66
+ this.disposed = true;
67
+ this.connector.offEvent("browsingContext.downloadWillBegin", this.onBegin);
68
+ this.connector.offEvent("browsingContext.downloadEnd", this.onEnd);
69
+ await this.subscription?.unsubscribe().catch(() => undefined);
70
+ if (this.staging)
71
+ this.removeIfEmpty(this.staging);
72
+ this.removeIfEmpty(this.stagingRoot());
73
+ }
74
+ stagingRoot() {
75
+ return path.join(this.dir, ".staging");
76
+ }
77
+ /** Points the browser at a new, empty folder for the download after this one */
78
+ async rotate() {
79
+ const previous = this.staging;
80
+ this.staging = path.join(this.stagingRoot(), String(this.counter++));
81
+ mkdirSync(this.staging, { recursive: true });
82
+ await this.connector.send("browser.setDownloadBehavior", {
83
+ downloadBehavior: { type: "allowed", destinationFolder: this.staging },
84
+ });
85
+ if (previous)
86
+ this.removeIfEmpty(previous);
87
+ }
88
+ removeIfEmpty(dir) {
89
+ try {
90
+ if (readdirSync(dir).length === 0)
91
+ rmSync(dir, { recursive: true });
92
+ }
93
+ catch {
94
+ // gone already, or not empty: leave it
95
+ }
96
+ }
97
+ }
@@ -39,6 +39,8 @@ export declare class ConsoleMessage {
39
39
  /** Where the call was made, when the browser says */
40
40
  location(): SourceLocation | undefined;
41
41
  }
42
+ /** The text of a page error as it reads on every browser */
43
+ export declare function pageErrorText(text: string): string;
42
44
  /** An exception nobody caught in the page */
43
45
  export declare class PageError extends Error {
44
46
  readonly timestamp: number;