@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.
- package/README.md +8 -8
- package/dist/browser/browser-finder.js +2 -2
- package/dist/browser/browser.d.ts +11 -15
- package/dist/browser/browser.js +79 -58
- package/dist/browser/chrome-launch.d.ts +57 -0
- package/dist/browser/chrome-launch.js +127 -0
- package/dist/browser/download-folders.d.ts +27 -0
- package/dist/browser/download-folders.js +97 -0
- package/dist/browser/page-logs.d.ts +2 -0
- package/dist/browser/page-logs.js +25 -5
- package/dist/browser/page.d.ts +18 -0
- package/dist/browser/page.js +99 -2
- package/dist/browser/screenshot.js +16 -2
- package/dist/browser/session-capabilities.d.ts +12 -0
- package/dist/browser/session-capabilities.js +8 -0
- package/dist/browsers/index.d.ts +2 -2
- package/dist/browsers/index.js +2 -2
- package/dist/browsers/overrides.d.ts +2 -0
- package/dist/browsers/overrides.js +2 -0
- package/dist/browsers/pins.d.ts +1 -1
- package/dist/browsers/pins.js +2 -2
- package/dist/browsers/resolve.d.ts +2 -0
- package/dist/browsers/resolve.js +14 -2
- package/dist/browsers/store.d.ts +2 -0
- package/dist/browsers/store.js +8 -0
- package/dist/locator/locator.js +21 -2
- package/dist/network/data-collector.d.ts +1 -0
- package/dist/network/data-collector.js +23 -5
- package/dist/network/network-tracker.d.ts +8 -1
- package/dist/network/network-tracker.js +42 -3
- package/dist/network/router.d.ts +5 -0
- package/dist/network/router.js +33 -11
- package/dist/recorder/session.js +4 -3
- package/dist/recorder/toolbar/controller.d.ts +6 -5
- package/dist/recorder/toolbar/controller.js +39 -7
- package/dist/script/preload.js +65 -0
- package/dist/transport/bidi-connection.d.ts +6 -0
- package/dist/transport/bidi-connection.js +13 -0
- package/dist/types/browser.d.ts +1 -1
- package/dist/types/config.d.ts +1 -1
- 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
|
|
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
|
[](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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
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() */
|
package/dist/browser/browser.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
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
|
|
120
|
-
|
|
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
|
|
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(
|
|
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] +
|
|
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
|
|
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;
|