@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
|
@@ -28,19 +28,37 @@ export class ConsoleMessage {
|
|
|
28
28
|
/** Where the call was made, when the browser says */
|
|
29
29
|
location() {
|
|
30
30
|
const frame = this.stackTrace?.callFrames[0];
|
|
31
|
-
return frame && {
|
|
31
|
+
return (frame && {
|
|
32
|
+
url: frame.url,
|
|
33
|
+
lineNumber: frame.lineNumber,
|
|
34
|
+
columnNumber: frame.columnNumber,
|
|
35
|
+
});
|
|
32
36
|
}
|
|
33
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Chrome starts the text of an unhandled promise rejection with this; Firefox does not. Removed, so a page error
|
|
40
|
+
* reads the same on both browsers.
|
|
41
|
+
*/
|
|
42
|
+
const REJECTION_PREFIX = "Uncaught (in promise) ";
|
|
43
|
+
/** The text of a page error as it reads on every browser */
|
|
44
|
+
export function pageErrorText(text) {
|
|
45
|
+
return text.startsWith(REJECTION_PREFIX)
|
|
46
|
+
? text.slice(REJECTION_PREFIX.length)
|
|
47
|
+
: text;
|
|
48
|
+
}
|
|
34
49
|
/** An exception nobody caught in the page */
|
|
35
50
|
export class PageError extends Error {
|
|
36
51
|
constructor(text, timestamp, stackTrace) {
|
|
37
52
|
super(text);
|
|
38
53
|
this.name = "PageError";
|
|
39
54
|
this.timestamp = timestamp;
|
|
40
|
-
this.stack = [
|
|
55
|
+
this.stack = [
|
|
56
|
+
text,
|
|
57
|
+
...(stackTrace?.callFrames ?? []).map(formatFrame),
|
|
58
|
+
].join("\n");
|
|
41
59
|
}
|
|
42
60
|
}
|
|
43
|
-
function formatFrame({ functionName, url, lineNumber, columnNumber }) {
|
|
61
|
+
function formatFrame({ functionName, url, lineNumber, columnNumber, }) {
|
|
44
62
|
const where = `${url}:${lineNumber}:${columnNumber}`;
|
|
45
63
|
return functionName ? ` at ${functionName} (${where})` : ` at ${where}`;
|
|
46
64
|
}
|
|
@@ -68,7 +86,9 @@ export class PageLogs {
|
|
|
68
86
|
const record = {
|
|
69
87
|
level: entry.level,
|
|
70
88
|
type: entry.type,
|
|
71
|
-
...(entry.type === "console" && {
|
|
89
|
+
...(entry.type === "console" && {
|
|
90
|
+
method: entry.method,
|
|
91
|
+
}),
|
|
72
92
|
text,
|
|
73
93
|
timestamp: entry.timestamp,
|
|
74
94
|
};
|
|
@@ -82,7 +102,7 @@ export class PageLogs {
|
|
|
82
102
|
this.emit("console", new ConsoleMessage(entry.level, consoleEntry.method, text, (consoleEntry.args ?? []).map((arg) => fromRemoteValue(arg)), entry.timestamp, entry.stackTrace));
|
|
83
103
|
}
|
|
84
104
|
else if (entry.type === "javascript") {
|
|
85
|
-
const error = new PageError(text, entry.timestamp, entry.stackTrace);
|
|
105
|
+
const error = new PageError(pageErrorText(text), entry.timestamp, entry.stackTrace);
|
|
86
106
|
this.uncaught.push(error);
|
|
87
107
|
if (this.uncaught.length > this.maxEntries)
|
|
88
108
|
this.uncaught.shift();
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -171,6 +171,17 @@ export default class Page {
|
|
|
171
171
|
/** Goes forward one entry in the history. False when there is none */
|
|
172
172
|
goForward(): Promise<boolean>;
|
|
173
173
|
private traverse;
|
|
174
|
+
/**
|
|
175
|
+
* Whether going back would only reach the blank page a tab starts with. Firefox does not keep that page in the
|
|
176
|
+
* history, Chrome does; the Navigation API (Chrome only) lets Chrome say the same as Firefox.
|
|
177
|
+
*/
|
|
178
|
+
private atFirstEntry;
|
|
179
|
+
/**
|
|
180
|
+
* Starts listening for the end of a history traversal (the new entry committed, or the history or fragment changed
|
|
181
|
+
* within the document). `done` resolves at the first such event, or after a second without one (a page restored from
|
|
182
|
+
* the back-forward cache may say nothing); it never rejects.
|
|
183
|
+
*/
|
|
184
|
+
private watchHistoryTraversal;
|
|
174
185
|
/** The URL of the page now */
|
|
175
186
|
url(): Promise<string>;
|
|
176
187
|
/** The title of the document now */
|
|
@@ -185,6 +196,13 @@ export default class Page {
|
|
|
185
196
|
height: number;
|
|
186
197
|
devicePixelRatio?: number;
|
|
187
198
|
} | null): Promise<void>;
|
|
199
|
+
/**
|
|
200
|
+
* Runs `change`, then waits until the page has stopped resizing. Chrome answers a viewport reset before the window
|
|
201
|
+
* has its own size back, so a read right after it can still show the old size. The wait ends 50 ms after the last
|
|
202
|
+
* `resize` event, and after 300 ms when the size does not change at all (no event comes). Never fails: a page that
|
|
203
|
+
* cannot be watched is simply not waited for.
|
|
204
|
+
*/
|
|
205
|
+
private untilResized;
|
|
188
206
|
/** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
|
|
189
207
|
applyDefaultViewport(): Promise<void>;
|
|
190
208
|
/**
|
package/dist/browser/page.js
CHANGED
|
@@ -31,6 +31,8 @@ import { LoadStateTimeoutError, NavigationError } from "./navigation-error.js";
|
|
|
31
31
|
import { callFunction } from "../script/call-function.js";
|
|
32
32
|
/** Time (ms) a navigation or a wait for a load state may take */
|
|
33
33
|
const DEFAULT_NAVIGATION_TIMEOUT = 30000;
|
|
34
|
+
/** Time (ms) a history traversal may go without a sign that the entry is shown before it is taken as done */
|
|
35
|
+
const HISTORY_SETTLE_TIMEOUT = 1000;
|
|
34
36
|
/** Time (ms) without requests in flight that counts as idle */
|
|
35
37
|
const DEFAULT_IDLE_TIME = 500;
|
|
36
38
|
/** The size a page gets unless config `use.viewport` says otherwise */
|
|
@@ -336,11 +338,17 @@ export default class Page {
|
|
|
336
338
|
}
|
|
337
339
|
async traverse(delta) {
|
|
338
340
|
this.assertOpen();
|
|
341
|
+
if (delta === -1 && (await this.atFirstEntry()))
|
|
342
|
+
return false;
|
|
343
|
+
const settled = await this.watchHistoryTraversal();
|
|
339
344
|
try {
|
|
340
345
|
await this.biDiConnector.send("browsingContext.traverseHistory", {
|
|
341
346
|
context: this.id,
|
|
342
347
|
delta,
|
|
343
348
|
});
|
|
349
|
+
// Firefox answers once the entry is shown; Chrome answers before, and a script call in between can hit the
|
|
350
|
+
// document that is going away
|
|
351
|
+
await settled.done;
|
|
344
352
|
return true;
|
|
345
353
|
}
|
|
346
354
|
catch (err) {
|
|
@@ -348,6 +356,66 @@ export default class Page {
|
|
|
348
356
|
return false;
|
|
349
357
|
throw err;
|
|
350
358
|
}
|
|
359
|
+
finally {
|
|
360
|
+
await settled.stop();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Whether going back would only reach the blank page a tab starts with. Firefox does not keep that page in the
|
|
365
|
+
* history, Chrome does; the Navigation API (Chrome only) lets Chrome say the same as Firefox.
|
|
366
|
+
*/
|
|
367
|
+
async atFirstEntry() {
|
|
368
|
+
try {
|
|
369
|
+
return await callFunction(this.biDiConnector, this.id, `() => {
|
|
370
|
+
const nav = globalThis.navigation;
|
|
371
|
+
if (!nav) return false;
|
|
372
|
+
const entries = nav.entries();
|
|
373
|
+
const index = nav.currentEntry.index;
|
|
374
|
+
return index === 0 || (index === 1 && entries[0].url === "about:blank");
|
|
375
|
+
}`, [], { awaitPromise: false });
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
logger.debug("Could not read the history position", { err });
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Starts listening for the end of a history traversal (the new entry committed, or the history or fragment changed
|
|
384
|
+
* within the document). `done` resolves at the first such event, or after a second without one (a page restored from
|
|
385
|
+
* the back-forward cache may say nothing); it never rejects.
|
|
386
|
+
*/
|
|
387
|
+
async watchHistoryTraversal() {
|
|
388
|
+
const events = [
|
|
389
|
+
"browsingContext.navigationCommitted",
|
|
390
|
+
"browsingContext.historyUpdated",
|
|
391
|
+
"browsingContext.fragmentNavigated",
|
|
392
|
+
];
|
|
393
|
+
let subscription;
|
|
394
|
+
try {
|
|
395
|
+
subscription = await this.biDiConnector.subscribe([...events]);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
logger.debug("Could not watch a history traversal", { err });
|
|
399
|
+
return { done: Promise.resolve(), stop: async () => { } };
|
|
400
|
+
}
|
|
401
|
+
let finish;
|
|
402
|
+
const done = new Promise((resolve) => (finish = resolve));
|
|
403
|
+
const timer = setTimeout(finish, HISTORY_SETTLE_TIMEOUT);
|
|
404
|
+
const listener = (info) => {
|
|
405
|
+
if (info.context === this.id)
|
|
406
|
+
finish();
|
|
407
|
+
};
|
|
408
|
+
for (const event of events)
|
|
409
|
+
this.biDiConnector.onEvent(event, listener);
|
|
410
|
+
return {
|
|
411
|
+
done,
|
|
412
|
+
stop: async () => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
for (const event of events)
|
|
415
|
+
this.biDiConnector.offEvent(event, listener);
|
|
416
|
+
await subscription?.unsubscribe().catch(() => undefined);
|
|
417
|
+
},
|
|
418
|
+
};
|
|
351
419
|
}
|
|
352
420
|
/** The URL of the page now */
|
|
353
421
|
async url() {
|
|
@@ -367,11 +435,11 @@ export default class Page {
|
|
|
367
435
|
async setViewport(viewport) {
|
|
368
436
|
this.assertOpen();
|
|
369
437
|
if (viewport === null) {
|
|
370
|
-
await this.biDiConnector.send("browsingContext.setViewport", {
|
|
438
|
+
await this.untilResized(() => this.biDiConnector.send("browsingContext.setViewport", {
|
|
371
439
|
context: this.id,
|
|
372
440
|
viewport: null,
|
|
373
441
|
devicePixelRatio: null,
|
|
374
|
-
});
|
|
442
|
+
}));
|
|
375
443
|
return;
|
|
376
444
|
}
|
|
377
445
|
const { width, height, devicePixelRatio } = viewport;
|
|
@@ -393,6 +461,35 @@ export default class Page {
|
|
|
393
461
|
...(devicePixelRatio !== undefined && { devicePixelRatio }),
|
|
394
462
|
});
|
|
395
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Runs `change`, then waits until the page has stopped resizing. Chrome answers a viewport reset before the window
|
|
466
|
+
* has its own size back, so a read right after it can still show the old size. The wait ends 50 ms after the last
|
|
467
|
+
* `resize` event, and after 300 ms when the size does not change at all (no event comes). Never fails: a page that
|
|
468
|
+
* cannot be watched is simply not waited for.
|
|
469
|
+
*/
|
|
470
|
+
async untilResized(change) {
|
|
471
|
+
const sandbox = { sandbox: "samurai-viewport" };
|
|
472
|
+
const watching = await callFunction(this.biDiConnector, this.id, `() => {
|
|
473
|
+
globalThis.__resized = new Promise((resolve) => {
|
|
474
|
+
let timer;
|
|
475
|
+
const finish = () => {
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
removeEventListener("resize", onResize);
|
|
478
|
+
resolve();
|
|
479
|
+
};
|
|
480
|
+
const onResize = () => {
|
|
481
|
+
clearTimeout(timer);
|
|
482
|
+
timer = setTimeout(finish, 50);
|
|
483
|
+
};
|
|
484
|
+
addEventListener("resize", onResize);
|
|
485
|
+
timer = setTimeout(finish, 300);
|
|
486
|
+
});
|
|
487
|
+
}`, [], { ...sandbox, awaitPromise: false }).then(() => true, () => false);
|
|
488
|
+
await change();
|
|
489
|
+
if (!watching)
|
|
490
|
+
return;
|
|
491
|
+
await callFunction(this.biDiConnector, this.id, "() => globalThis.__resized", [], sandbox).catch(() => undefined);
|
|
492
|
+
}
|
|
396
493
|
/** Gives the page the size from config `use.viewport` (1280x720 by default); the browser does this when it opens a page */
|
|
397
494
|
async applyDefaultViewport() {
|
|
398
495
|
const { viewport } = this.settings?.use ?? (await readSection("use"));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { callFunction } from "../script/call-function.js";
|
|
4
|
+
import { ElementHandle } from "../script/element-handle.js";
|
|
3
5
|
const MIME = { png: "image/png", jpeg: "image/jpeg" };
|
|
4
6
|
/** The `format` of a capture command; validates the type/quality combination */
|
|
5
7
|
function imageFormat({ type, quality, }) {
|
|
@@ -23,12 +25,17 @@ function imageFormat({ type, quality, }) {
|
|
|
23
25
|
*/
|
|
24
26
|
export async function takeScreenshot(connector, context, options = {}, element) {
|
|
25
27
|
const format = imageFormat(options);
|
|
28
|
+
// An element is captured as its box in the document: Chrome cannot capture an `element` clip that is not
|
|
29
|
+
// wholly in the viewport, and this works the same for an element taller than the viewport
|
|
26
30
|
const clip = element
|
|
27
|
-
? {
|
|
31
|
+
? {
|
|
32
|
+
type: "box",
|
|
33
|
+
...(await documentBox(connector, context, element)),
|
|
34
|
+
}
|
|
28
35
|
: options.clip && { type: "box", ...options.clip };
|
|
29
36
|
const { data } = await connector.send("browsingContext.captureScreenshot", {
|
|
30
37
|
context,
|
|
31
|
-
...(options.fullPage && { origin: "document" }),
|
|
38
|
+
...((options.fullPage || element) && { origin: "document" }),
|
|
32
39
|
...(format && { format }),
|
|
33
40
|
...(clip && { clip }),
|
|
34
41
|
});
|
|
@@ -36,6 +43,13 @@ export async function takeScreenshot(connector, context, options = {}, element)
|
|
|
36
43
|
await writeOutput(options.path, image);
|
|
37
44
|
return image;
|
|
38
45
|
}
|
|
46
|
+
/** The element's box in document coordinates (CSS pixels) */
|
|
47
|
+
async function documentBox(connector, context, element) {
|
|
48
|
+
return callFunction(connector, context, `(el) => {
|
|
49
|
+
const r = el.getBoundingClientRect();
|
|
50
|
+
return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
|
|
51
|
+
}`, [new ElementHandle(element.sharedId)]);
|
|
52
|
+
}
|
|
39
53
|
/** Writes `data` to `file`, creating the folders; nothing happens without a path */
|
|
40
54
|
export async function writeOutput(file, data) {
|
|
41
55
|
if (!file)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sent with session.new (Firefox) or as capabilities of the chromedriver session (Chrome). With "ignore" the browser
|
|
3
|
+
* leaves a JavaScript dialog open and reports it (`browsingContext.userPromptOpened`), so the framework decides how
|
|
4
|
+
* to answer instead of the browser.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SESSION_CAPABILITIES: {
|
|
7
|
+
alwaysMatch: {
|
|
8
|
+
unhandledPromptBehavior: {
|
|
9
|
+
default: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sent with session.new (Firefox) or as capabilities of the chromedriver session (Chrome). With "ignore" the browser
|
|
3
|
+
* leaves a JavaScript dialog open and reports it (`browsingContext.userPromptOpened`), so the framework decides how
|
|
4
|
+
* to answer instead of the browser.
|
|
5
|
+
*/
|
|
6
|
+
export const SESSION_CAPABILITIES = {
|
|
7
|
+
alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } },
|
|
8
|
+
};
|
package/dist/browsers/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { browsersCacheDir } from "./cache-dir.js";
|
|
2
2
|
export type { CachedBuild, Downloader } from "./downloader.js";
|
|
3
|
-
export { ENV_PATH, type Override } from "./overrides.js";
|
|
3
|
+
export { ENV_CHROMEDRIVER_PATH, ENV_PATH, type Override } from "./overrides.js";
|
|
4
4
|
export { LAUNCHABLE, MANAGED_BROWSERS, PINNED, isManagedBrowser, type ManagedBrowser, } from "./pins.js";
|
|
5
5
|
export { browserStatus, projectBrowser, projectExecutable, resolveBrowser, resolveExecutable, type ResolvedBrowser, } from "./resolve.js";
|
|
6
|
-
export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedExecutable, uninstallBrowser, type BrowserStatus, type InstalledBuild, type StoreOptions, } from "./store.js";
|
|
6
|
+
export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedChromedriver, managedExecutable, uninstallBrowser, type BrowserStatus, type InstalledBuild, type StoreOptions, } from "./store.js";
|
package/dist/browsers/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { browsersCacheDir } from "./cache-dir.js";
|
|
2
|
-
export { ENV_PATH } from "./overrides.js";
|
|
2
|
+
export { ENV_CHROMEDRIVER_PATH, ENV_PATH } from "./overrides.js";
|
|
3
3
|
export { LAUNCHABLE, MANAGED_BROWSERS, PINNED, isManagedBrowser, } from "./pins.js";
|
|
4
4
|
export { browserStatus, projectBrowser, projectExecutable, resolveBrowser, resolveExecutable, } from "./resolve.js";
|
|
5
|
-
export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedExecutable, uninstallBrowser, } from "./store.js";
|
|
5
|
+
export { BrowserNotInstalledError, installBrowser, listInstalled, managedBuildDir, managedChromedriver, managedExecutable, uninstallBrowser, } from "./store.js";
|
|
@@ -12,6 +12,8 @@ export interface OverrideSources {
|
|
|
12
12
|
/** What relative `browserPaths` resolve from */
|
|
13
13
|
projectDir?: string;
|
|
14
14
|
}
|
|
15
|
+
/** Chrome is driven through a chromedriver of the same major version; this points a launch at another one */
|
|
16
|
+
export declare const ENV_CHROMEDRIVER_PATH = "SAMURAI_CHROMEDRIVER_PATH";
|
|
15
17
|
export declare const ENV_PATH: Record<ManagedBrowser, string>;
|
|
16
18
|
/** The env variable, else the config's `browserPaths`; undefined: use the managed build */
|
|
17
19
|
export declare function overrideOf(name: ManagedBrowser, { env, browserPaths, projectDir, }?: OverrideSources): Override | undefined;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
/** Chrome is driven through a chromedriver of the same major version; this points a launch at another one */
|
|
3
|
+
export const ENV_CHROMEDRIVER_PATH = "SAMURAI_CHROMEDRIVER_PATH";
|
|
2
4
|
export const ENV_PATH = {
|
|
3
5
|
firefox: "SAMURAI_FIREFOX_PATH",
|
|
4
6
|
chrome: "SAMURAI_CHROME_PATH",
|
package/dist/browsers/pins.d.ts
CHANGED
|
@@ -11,6 +11,6 @@ export declare const PINNED: {
|
|
|
11
11
|
};
|
|
12
12
|
export type ManagedBrowser = keyof typeof PINNED;
|
|
13
13
|
export declare const MANAGED_BROWSERS: ManagedBrowser[];
|
|
14
|
-
/** The managed browsers the framework can launch
|
|
14
|
+
/** The managed browsers the framework can launch (Chrome through its chromedriver) */
|
|
15
15
|
export declare const LAUNCHABLE: readonly ManagedBrowser[];
|
|
16
16
|
export declare function isManagedBrowser(name: string): name is ManagedBrowser;
|
package/dist/browsers/pins.js
CHANGED
|
@@ -7,8 +7,8 @@ export const PINNED = {
|
|
|
7
7
|
},
|
|
8
8
|
};
|
|
9
9
|
export const MANAGED_BROWSERS = Object.keys(PINNED);
|
|
10
|
-
/** The managed browsers the framework can launch
|
|
11
|
-
export const LAUNCHABLE = ["firefox"];
|
|
10
|
+
/** The managed browsers the framework can launch (Chrome through its chromedriver) */
|
|
11
|
+
export const LAUNCHABLE = ["firefox", "chrome"];
|
|
12
12
|
export function isManagedBrowser(name) {
|
|
13
13
|
return Object.prototype.hasOwnProperty.call(PINNED, name);
|
|
14
14
|
}
|
|
@@ -9,6 +9,8 @@ export interface ResolvedBrowser {
|
|
|
9
9
|
label: string;
|
|
10
10
|
/** The managed build's folder; undefined when an env or config path is used instead */
|
|
11
11
|
buildDir?: string;
|
|
12
|
+
/** Chrome only: the chromedriver that starts it and speaks BiDi for it */
|
|
13
|
+
driverPath?: string;
|
|
12
14
|
}
|
|
13
15
|
/** The browser a launch uses: the env path, else the config's `browserPaths`, else the pinned build. Never downloads */
|
|
14
16
|
export declare function resolveBrowser(name: ManagedBrowser, sources?: OverrideSources & StoreOptions): ResolvedBrowser;
|
package/dist/browsers/resolve.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { projectConfig, projectDir, readConfig } from "../config/config.js";
|
|
3
3
|
import logger from "../logger/index.js";
|
|
4
|
-
import { ENV_PATH, overrideOf } from "./overrides.js";
|
|
4
|
+
import { ENV_CHROMEDRIVER_PATH, ENV_PATH, overrideOf, } from "./overrides.js";
|
|
5
5
|
import { MANAGED_BROWSERS, PINNED } from "./pins.js";
|
|
6
|
-
import { managedBuildDir, managedExecutable, statusOf, } from "./store.js";
|
|
6
|
+
import { managedBuildDir, managedChromedriver, managedExecutable, statusOf, } from "./store.js";
|
|
7
7
|
/** The browser a launch uses: the env path, else the config's `browserPaths`, else the pinned build. Never downloads */
|
|
8
8
|
export function resolveBrowser(name, sources = {}) {
|
|
9
|
+
const driverPath = name === "chrome" ? resolveChromedriver(sources) : undefined;
|
|
9
10
|
const override = overrideOf(name, sources);
|
|
10
11
|
if (!override)
|
|
11
12
|
return {
|
|
12
13
|
executablePath: managedExecutable(name, sources),
|
|
13
14
|
label: PINNED[name].label,
|
|
14
15
|
buildDir: managedBuildDir(name, sources),
|
|
16
|
+
...(driverPath && { driverPath }),
|
|
15
17
|
};
|
|
16
18
|
if (!existsSync(override.path))
|
|
17
19
|
throw new Error(override.from === "env"
|
|
@@ -20,8 +22,18 @@ export function resolveBrowser(name, sources = {}) {
|
|
|
20
22
|
return {
|
|
21
23
|
executablePath: override.path,
|
|
22
24
|
label: name.charAt(0).toUpperCase() + name.slice(1),
|
|
25
|
+
...(driverPath && { driverPath }),
|
|
23
26
|
};
|
|
24
27
|
}
|
|
28
|
+
/** `SAMURAI_CHROMEDRIVER_PATH` (which must exist), else the chromedriver installed with the pinned Chrome */
|
|
29
|
+
function resolveChromedriver(sources) {
|
|
30
|
+
const fromEnv = (sources.env ?? process.env)[ENV_CHROMEDRIVER_PATH];
|
|
31
|
+
if (!fromEnv)
|
|
32
|
+
return managedChromedriver(sources);
|
|
33
|
+
if (!existsSync(fromEnv))
|
|
34
|
+
throw new Error(`${ENV_CHROMEDRIVER_PATH} points to a missing file: ${fromEnv}`);
|
|
35
|
+
return fromEnv;
|
|
36
|
+
}
|
|
25
37
|
/** The binary a launch uses; see `resolveBrowser` */
|
|
26
38
|
export function resolveExecutable(name, sources = {}) {
|
|
27
39
|
return resolveBrowser(name, sources).executablePath;
|
package/dist/browsers/store.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export declare function installBrowser(name: ManagedBrowser, options?: StoreOpti
|
|
|
44
44
|
export declare function statusOf(name: ManagedBrowser, options?: StoreOptions & OverrideSources): BrowserStatus;
|
|
45
45
|
/** The pinned build's executable; throws BrowserNotInstalledError when it isn't complete */
|
|
46
46
|
export declare function managedExecutable(name: ManagedBrowser, options?: StoreOptions): string;
|
|
47
|
+
/** The chromedriver installed with the pinned Chrome; throws BrowserNotInstalledError when Chrome's install isn't complete */
|
|
48
|
+
export declare function managedChromedriver(options?: StoreOptions): string;
|
|
47
49
|
/** The folder the pinned build of `name` is, or would be, installed in */
|
|
48
50
|
export declare function managedBuildDir(name: ManagedBrowser, options?: StoreOptions): string;
|
|
49
51
|
/** Removes a build (default: the pinned one); Chrome takes its chromedriver along */
|
package/dist/browsers/store.js
CHANGED
|
@@ -127,6 +127,14 @@ export function managedExecutable(name, options = {}) {
|
|
|
127
127
|
throw new BrowserNotInstalledError(name, status.label);
|
|
128
128
|
return status.executablePath;
|
|
129
129
|
}
|
|
130
|
+
/** The chromedriver installed with the pinned Chrome; throws BrowserNotInstalledError when Chrome's install isn't complete */
|
|
131
|
+
export function managedChromedriver(options = {}) {
|
|
132
|
+
const { cacheDir, downloader } = store(options);
|
|
133
|
+
const status = statusOf("chrome", { ...options, env: {} });
|
|
134
|
+
if (status.state !== "installed")
|
|
135
|
+
throw new BrowserNotInstalledError("chrome", status.label);
|
|
136
|
+
return downloader.executablePath(cacheDir, "chromedriver", PINNED.chrome.buildId);
|
|
137
|
+
}
|
|
130
138
|
/** The folder the pinned build of `name` is, or would be, installed in */
|
|
131
139
|
export function managedBuildDir(name, options = {}) {
|
|
132
140
|
const { cacheDir, downloader } = store(options);
|
package/dist/locator/locator.js
CHANGED
|
@@ -88,6 +88,16 @@ const NAMED_KEYS = {
|
|
|
88
88
|
};
|
|
89
89
|
/** Unicode code point WebDriver uses for the Backspace key */
|
|
90
90
|
const BACKSPACE = "";
|
|
91
|
+
/**
|
|
92
|
+
* Chrome answers a command sent while the page swaps documents with an unknown error that says the context
|
|
93
|
+
* is gone for the moment; the same call a moment later works.
|
|
94
|
+
*/
|
|
95
|
+
const DOCUMENT_SWAP = /Cannot find context with specified id|execution contexts? (was |were )?(cleared|destroyed)/i;
|
|
96
|
+
function isDocumentSwap(err) {
|
|
97
|
+
return (err instanceof BiDiError &&
|
|
98
|
+
err.code === "unknown error" &&
|
|
99
|
+
DOCUMENT_SWAP.test(err.message));
|
|
100
|
+
}
|
|
91
101
|
export default class Locator {
|
|
92
102
|
/**
|
|
93
103
|
* `selector` is an xpath, or a chain of selector steps that each search inside the previous step's matches.
|
|
@@ -216,7 +226,15 @@ export default class Locator {
|
|
|
216
226
|
nodes = picked ? [picked] : [];
|
|
217
227
|
}
|
|
218
228
|
else {
|
|
219
|
-
|
|
229
|
+
try {
|
|
230
|
+
nodes = await this.locateStep(selector, index > 0, nodes, index === this.chain.length - 1 ? max : undefined);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
// The page is swapping documents: nothing matches right now, and the waits that poll try again
|
|
234
|
+
if (isDocumentSwap(err))
|
|
235
|
+
return [];
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
220
238
|
}
|
|
221
239
|
if (nodes.length === 0)
|
|
222
240
|
return [];
|
|
@@ -335,7 +353,8 @@ export default class Locator {
|
|
|
335
353
|
}
|
|
336
354
|
catch (err) {
|
|
337
355
|
// The document changed between the search and the probe
|
|
338
|
-
if (err instanceof BiDiError && err.code === "no such node")
|
|
356
|
+
if ((err instanceof BiDiError && err.code === "no such node") ||
|
|
357
|
+
isDocumentSwap(err))
|
|
339
358
|
return DETACHED_STATE;
|
|
340
359
|
throw err;
|
|
341
360
|
}
|
|
@@ -12,6 +12,7 @@ export declare class ResponseBodyUnavailableError extends Error {
|
|
|
12
12
|
export declare class DataCollector {
|
|
13
13
|
private connector;
|
|
14
14
|
private id;
|
|
15
|
+
private maxBodySize;
|
|
15
16
|
private disposed;
|
|
16
17
|
private constructor();
|
|
17
18
|
static start(connector: BiDiConnector, contextId: string, options?: {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { BiDiError } from "../transport/bidi-error.js";
|
|
2
2
|
/** How large a response body may be to be kept: 10 MiB */
|
|
3
3
|
const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
4
|
+
/**
|
|
5
|
+
* Chrome counts the headers of a response in the size it compares with the collector's limit (a 14-byte body
|
|
6
|
+
* arrives as about 200 bytes), Firefox counts the body. Chrome's limit is raised by this much and the body is
|
|
7
|
+
* measured after it is read.
|
|
8
|
+
*/
|
|
9
|
+
const CHROME_HEADER_ALLOWANCE = 64 * 1024;
|
|
4
10
|
/** The body of a response cannot be read */
|
|
5
11
|
export class ResponseBodyUnavailableError extends Error {
|
|
6
12
|
constructor(url, reason) {
|
|
@@ -15,18 +21,22 @@ export class ResponseBodyUnavailableError extends Error {
|
|
|
15
21
|
* started. This registers one for a page and reads bodies through it.
|
|
16
22
|
*/
|
|
17
23
|
export class DataCollector {
|
|
18
|
-
constructor(connector, id) {
|
|
24
|
+
constructor(connector, id, maxBodySize) {
|
|
19
25
|
this.connector = connector;
|
|
20
26
|
this.id = id;
|
|
27
|
+
this.maxBodySize = maxBodySize;
|
|
21
28
|
this.disposed = false;
|
|
22
29
|
}
|
|
23
30
|
static async start(connector, contextId, options = {}) {
|
|
31
|
+
const maxBodySize = options.maxBodySize ?? DEFAULT_MAX_BODY_SIZE;
|
|
24
32
|
const { collector } = await connector.send("network.addDataCollector", {
|
|
25
33
|
dataTypes: ["response"],
|
|
26
|
-
maxEncodedDataSize:
|
|
34
|
+
maxEncodedDataSize: connector.browserName === "chrome"
|
|
35
|
+
? maxBodySize + CHROME_HEADER_ALLOWANCE
|
|
36
|
+
: maxBodySize,
|
|
27
37
|
contexts: [contextId],
|
|
28
38
|
});
|
|
29
|
-
return new DataCollector(connector, collector);
|
|
39
|
+
return new DataCollector(connector, collector, maxBodySize);
|
|
30
40
|
}
|
|
31
41
|
/** The body of the response to request `requestId`; `url` only names it in errors */
|
|
32
42
|
async read(requestId, url) {
|
|
@@ -36,7 +46,13 @@ export class DataCollector {
|
|
|
36
46
|
request: requestId,
|
|
37
47
|
collector: this.id,
|
|
38
48
|
});
|
|
39
|
-
|
|
49
|
+
const body = bytes.type === "base64"
|
|
50
|
+
? Buffer.from(bytes.value, "base64")
|
|
51
|
+
: Buffer.from(bytes.value, "utf8");
|
|
52
|
+
if (body.length > this.maxBodySize) {
|
|
53
|
+
throw new ResponseBodyUnavailableError(url, `the response is larger than maxBodySize (${this.maxBodySize} bytes)`);
|
|
54
|
+
}
|
|
55
|
+
return body;
|
|
40
56
|
}
|
|
41
57
|
catch (err) {
|
|
42
58
|
if (err instanceof BiDiError && err.code === "no such network data") {
|
|
@@ -67,7 +83,9 @@ export class DataCollector {
|
|
|
67
83
|
return;
|
|
68
84
|
this.disposed = true;
|
|
69
85
|
try {
|
|
70
|
-
await this.connector.send("network.removeDataCollector", {
|
|
86
|
+
await this.connector.send("network.removeDataCollector", {
|
|
87
|
+
collector: this.id,
|
|
88
|
+
});
|
|
71
89
|
}
|
|
72
90
|
catch (err) {
|
|
73
91
|
if (!(err instanceof BiDiError && err.code === "no such network collector"))
|
|
@@ -54,6 +54,8 @@ export declare class NetworkTracker {
|
|
|
54
54
|
private connector;
|
|
55
55
|
private events;
|
|
56
56
|
private active;
|
|
57
|
+
/** The context each request in flight belongs to, by the same key */
|
|
58
|
+
private contexts;
|
|
57
59
|
/** Requests that may still be the source of a redirect, by hop key */
|
|
58
60
|
private hops;
|
|
59
61
|
private waiters;
|
|
@@ -73,7 +75,7 @@ export declare class NetworkTracker {
|
|
|
73
75
|
* Resolves once no request has been in flight for `idleTime` ms. A quiet page resolves after `idleTime`.
|
|
74
76
|
* Rejects with NetworkIdleTimeoutError after `timeout` ms, or with "page closed" when disposed.
|
|
75
77
|
*/
|
|
76
|
-
waitForIdle({ idleTime, timeout }: {
|
|
78
|
+
waitForIdle({ idleTime, timeout, }: {
|
|
77
79
|
idleTime: number;
|
|
78
80
|
timeout: number;
|
|
79
81
|
}): Promise<void>;
|
|
@@ -85,6 +87,11 @@ export declare class NetworkTracker {
|
|
|
85
87
|
private onBeforeRequestSent;
|
|
86
88
|
private onResponseCompleted;
|
|
87
89
|
private onFetchError;
|
|
90
|
+
/**
|
|
91
|
+
* A document replaced another: the requests of the old one that are still in flight are over. Firefox reports each
|
|
92
|
+
* as failed (NS_BINDING_ABORTED); Chrome says nothing, which would keep the page "busy" for good.
|
|
93
|
+
*/
|
|
94
|
+
private onNavigationCommitted;
|
|
88
95
|
/** A listener that throws must not stop the tracker or the other listeners */
|
|
89
96
|
private emit;
|
|
90
97
|
}
|