@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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import EventEmitter from "node:events";
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
3
|
import { WaitTimeoutError } from "../wait/wait-until.js";
|
|
4
|
+
/** The error text of a request that the page left before it finished, when the browser says none */
|
|
5
|
+
const ABORTED_BY_NAVIGATION = "aborted by navigation";
|
|
4
6
|
/** How many URLs a timeout message names */
|
|
5
7
|
const URLS_IN_MESSAGE = 5;
|
|
6
8
|
/** waitForIdle ran out of time with requests still in flight; `last` holds (up to five of) their URLs */
|
|
@@ -24,6 +26,8 @@ export class NetworkTracker {
|
|
|
24
26
|
this.connector = connector;
|
|
25
27
|
this.events = new EventEmitter();
|
|
26
28
|
this.active = new Map();
|
|
29
|
+
/** The context each request in flight belongs to, by the same key */
|
|
30
|
+
this.contexts = new Map();
|
|
27
31
|
/** Requests that may still be the source of a redirect, by hop key */
|
|
28
32
|
this.hops = new Map();
|
|
29
33
|
this.waiters = new Set();
|
|
@@ -41,6 +45,8 @@ export class NetworkTracker {
|
|
|
41
45
|
return;
|
|
42
46
|
this.hops.set(key, request);
|
|
43
47
|
this.active.set(key, request.url);
|
|
48
|
+
if (params.context)
|
|
49
|
+
this.contexts.set(key, params.context);
|
|
44
50
|
this.emit("request", request);
|
|
45
51
|
this.events.emit("change");
|
|
46
52
|
};
|
|
@@ -51,6 +57,7 @@ export class NetworkTracker {
|
|
|
51
57
|
const request = this.hops.get(key);
|
|
52
58
|
if (!this.active.delete(key) || !request)
|
|
53
59
|
return;
|
|
60
|
+
this.contexts.delete(key);
|
|
54
61
|
const { response } = params;
|
|
55
62
|
// A redirect response keeps its request around: the next hop links back to it
|
|
56
63
|
if (response.status < 300 || response.status >= 400)
|
|
@@ -72,10 +79,34 @@ export class NetworkTracker {
|
|
|
72
79
|
const request = this.hops.get(key);
|
|
73
80
|
if (!this.active.delete(key) || !request)
|
|
74
81
|
return;
|
|
82
|
+
this.contexts.delete(key);
|
|
75
83
|
this.hops.delete(key);
|
|
76
84
|
this.emit("requestfailed", Object.assign(request, { errorText: params.errorText }));
|
|
77
85
|
this.events.emit("change");
|
|
78
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* A document replaced another: the requests of the old one that are still in flight are over. Firefox reports each
|
|
89
|
+
* as failed (NS_BINDING_ABORTED); Chrome says nothing, which would keep the page "busy" for good.
|
|
90
|
+
*/
|
|
91
|
+
this.onNavigationCommitted = (params) => {
|
|
92
|
+
if (!this.owns(params.context))
|
|
93
|
+
return;
|
|
94
|
+
let changed = false;
|
|
95
|
+
for (const [key, requestContext] of [...this.contexts]) {
|
|
96
|
+
const request = this.hops.get(key);
|
|
97
|
+
if (!request || request.navigation === params.navigation)
|
|
98
|
+
continue;
|
|
99
|
+
if (!this.tree.isWithin(requestContext, params.context))
|
|
100
|
+
continue;
|
|
101
|
+
this.contexts.delete(key);
|
|
102
|
+
this.active.delete(key);
|
|
103
|
+
this.hops.delete(key);
|
|
104
|
+
this.emit("requestfailed", Object.assign(request, { errorText: ABORTED_BY_NAVIGATION }));
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
if (changed)
|
|
108
|
+
this.events.emit("change");
|
|
109
|
+
};
|
|
79
110
|
}
|
|
80
111
|
static async start(connector, tree, contextId, options = {}) {
|
|
81
112
|
const tracker = new NetworkTracker(tree, contextId, options.ignore ?? (() => false), connector);
|
|
@@ -83,11 +114,13 @@ export class NetworkTracker {
|
|
|
83
114
|
connector.onEvent("network.beforeRequestSent", tracker.onBeforeRequestSent);
|
|
84
115
|
connector.onEvent("network.responseCompleted", tracker.onResponseCompleted);
|
|
85
116
|
connector.onEvent("network.fetchError", tracker.onFetchError);
|
|
117
|
+
connector.onEvent("browsingContext.navigationCommitted", tracker.onNavigationCommitted);
|
|
86
118
|
try {
|
|
87
119
|
tracker.subscription = await connector.subscribe([
|
|
88
120
|
"network.beforeRequestSent",
|
|
89
121
|
"network.responseCompleted",
|
|
90
122
|
"network.fetchError",
|
|
123
|
+
"browsingContext.navigationCommitted",
|
|
91
124
|
]);
|
|
92
125
|
}
|
|
93
126
|
catch (err) {
|
|
@@ -118,10 +151,12 @@ export class NetworkTracker {
|
|
|
118
151
|
* Resolves once no request has been in flight for `idleTime` ms. A quiet page resolves after `idleTime`.
|
|
119
152
|
* Rejects with NetworkIdleTimeoutError after `timeout` ms, or with "page closed" when disposed.
|
|
120
153
|
*/
|
|
121
|
-
waitForIdle({ idleTime, timeout }) {
|
|
154
|
+
waitForIdle({ idleTime, timeout, }) {
|
|
122
155
|
return new Promise((resolve, reject) => {
|
|
123
156
|
let idleTimer;
|
|
124
|
-
const waiter = {
|
|
157
|
+
const waiter = {
|
|
158
|
+
reject: (reason) => finish(() => reject(reason)),
|
|
159
|
+
};
|
|
125
160
|
const finish = (settle) => {
|
|
126
161
|
clearTimeout(idleTimer);
|
|
127
162
|
clearTimeout(deadline);
|
|
@@ -147,6 +182,7 @@ export class NetworkTracker {
|
|
|
147
182
|
this.disposed = true;
|
|
148
183
|
this.removeListeners();
|
|
149
184
|
this.active.clear();
|
|
185
|
+
this.contexts.clear();
|
|
150
186
|
this.hops.clear();
|
|
151
187
|
[...this.waiters].forEach((waiter) => waiter.reject(new Error("page closed")));
|
|
152
188
|
await this.subscription?.unsubscribe();
|
|
@@ -155,6 +191,7 @@ export class NetworkTracker {
|
|
|
155
191
|
this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
156
192
|
this.connector.offEvent("network.responseCompleted", this.onResponseCompleted);
|
|
157
193
|
this.connector.offEvent("network.fetchError", this.onFetchError);
|
|
194
|
+
this.connector.offEvent("browsingContext.navigationCommitted", this.onNavigationCommitted);
|
|
158
195
|
}
|
|
159
196
|
owns(context) {
|
|
160
197
|
return context !== null && this.tree.isWithin(context, this.contextId);
|
|
@@ -190,7 +227,9 @@ export function toRequest(data, navigation, isBlocked, redirectedFrom) {
|
|
|
190
227
|
export function toHeaders(headers) {
|
|
191
228
|
const result = {};
|
|
192
229
|
for (const { name, value } of headers) {
|
|
193
|
-
const text = value.type === "base64"
|
|
230
|
+
const text = value.type === "base64"
|
|
231
|
+
? Buffer.from(value.value, "base64").toString("utf8")
|
|
232
|
+
: value.value;
|
|
194
233
|
const key = name.toLowerCase();
|
|
195
234
|
result[key] = key in result ? `${result[key]}, ${text}` : text;
|
|
196
235
|
}
|
package/dist/network/router.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ interface RouteDeps {
|
|
|
35
35
|
next: () => Promise<void>;
|
|
36
36
|
/** Called once the route is answered, whichever way */
|
|
37
37
|
settled: (route: Route) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a replaced body needs a `content-length` of its own. Firefox keeps the old one and cuts the new body
|
|
40
|
+
* off; Chrome sets the right one itself and hangs the request when it is given one.
|
|
41
|
+
*/
|
|
42
|
+
sendsContentLength: boolean;
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
45
|
* A request the browser holds until it is answered. Answer it exactly once with `fulfill`, `continue`,
|
package/dist/network/router.js
CHANGED
|
@@ -2,13 +2,16 @@ import { STATUS_CODES } from "node:http";
|
|
|
2
2
|
import logger from "../logger/index.js";
|
|
3
3
|
import { BiDiError } from "../transport/bidi-error.js";
|
|
4
4
|
import { toRequest } from "./network-tracker.js";
|
|
5
|
-
import { isExactString, matchUrl, samePattern } from "./url-match.js";
|
|
5
|
+
import { isExactString, matchUrl, samePattern, } from "./url-match.js";
|
|
6
6
|
/** Time (ms) a handler may take to answer a request before the router lets it continue */
|
|
7
7
|
const DEFAULT_ROUTE_TIMEOUT = 30000;
|
|
8
8
|
/** How many handler errors the router keeps */
|
|
9
9
|
const MAX_ERRORS = 100;
|
|
10
10
|
function toHeaders(headers) {
|
|
11
|
-
return Object.entries(headers).map(([name, value]) => ({
|
|
11
|
+
return Object.entries(headers).map(([name, value]) => ({
|
|
12
|
+
name,
|
|
13
|
+
value: { type: "string", value },
|
|
14
|
+
}));
|
|
12
15
|
}
|
|
13
16
|
function toBytes(data) {
|
|
14
17
|
return typeof data === "string"
|
|
@@ -44,7 +47,8 @@ export class Route {
|
|
|
44
47
|
const text = options.json !== undefined ? JSON.stringify(options.json) : undefined;
|
|
45
48
|
const body = options.body ?? text;
|
|
46
49
|
const headers = { ...options.headers };
|
|
47
|
-
const contentType = options.contentType ??
|
|
50
|
+
const contentType = options.contentType ??
|
|
51
|
+
(text !== undefined ? "application/json" : undefined);
|
|
48
52
|
if (contentType && !hasHeader(headers, "content-type"))
|
|
49
53
|
headers["content-type"] = contentType;
|
|
50
54
|
if (!hasHeader(headers, "content-length")) {
|
|
@@ -67,14 +71,21 @@ export class Route {
|
|
|
67
71
|
let headers = options.headers;
|
|
68
72
|
if (options.postData !== undefined) {
|
|
69
73
|
const withoutLength = Object.fromEntries(Object.entries(headers ?? this.info.headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
70
|
-
headers =
|
|
74
|
+
headers = this.deps.sendsContentLength
|
|
75
|
+
? {
|
|
76
|
+
...withoutLength,
|
|
77
|
+
"content-length": String(Buffer.byteLength(options.postData)),
|
|
78
|
+
}
|
|
79
|
+
: withoutLength;
|
|
71
80
|
}
|
|
72
81
|
await this.answer("network.continueRequest", {
|
|
73
82
|
request: this.info.id,
|
|
74
83
|
...(options.url !== undefined && { url: options.url }),
|
|
75
84
|
...(options.method !== undefined && { method: options.method }),
|
|
76
85
|
...(headers !== undefined && { headers: toHeaders(headers) }),
|
|
77
|
-
...(options.postData !== undefined && {
|
|
86
|
+
...(options.postData !== undefined && {
|
|
87
|
+
body: toBytes(options.postData),
|
|
88
|
+
}),
|
|
78
89
|
});
|
|
79
90
|
}
|
|
80
91
|
/** Makes the request fail (the browser reports a network error to the page) */
|
|
@@ -146,7 +157,9 @@ export class Router {
|
|
|
146
157
|
if (!(params.intercepts ?? []).some((id) => this.interceptIds.has(id)))
|
|
147
158
|
return;
|
|
148
159
|
const info = toRequest(params.request, params.navigation, params.isBlocked, null);
|
|
149
|
-
const matching = this.routes
|
|
160
|
+
const matching = this.routes
|
|
161
|
+
.filter((entry) => matchUrl(entry.pattern, info.url))
|
|
162
|
+
.reverse();
|
|
150
163
|
this.dispatch(info, matching, 0).catch((err) => logger.error("Handling the request to %s failed", info.url, { err }));
|
|
151
164
|
};
|
|
152
165
|
this.routeTimeout = options.routeTimeout ?? DEFAULT_ROUTE_TIMEOUT;
|
|
@@ -171,7 +184,8 @@ export class Router {
|
|
|
171
184
|
}
|
|
172
185
|
/** Removes the routes for `pattern` (only the one with `handler`, when given) */
|
|
173
186
|
async unroute(pattern, handler) {
|
|
174
|
-
const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) &&
|
|
187
|
+
const removed = this.routes.filter((route) => samePattern(route.pattern, pattern) &&
|
|
188
|
+
(handler === undefined || route.handler === handler));
|
|
175
189
|
await this.remove(removed);
|
|
176
190
|
}
|
|
177
191
|
async unrouteAll() {
|
|
@@ -189,7 +203,9 @@ export class Router {
|
|
|
189
203
|
return;
|
|
190
204
|
this.routes = this.routes.filter((route) => !removed.includes(route));
|
|
191
205
|
// Requests a removed route still holds would wait for nobody
|
|
192
|
-
await Promise.all([...this.pending]
|
|
206
|
+
await Promise.all([...this.pending]
|
|
207
|
+
.filter((route) => removed.includes(route.entry))
|
|
208
|
+
.map((route) => route.release()));
|
|
193
209
|
await this.enqueue(() => this.sync());
|
|
194
210
|
}
|
|
195
211
|
enqueue(work) {
|
|
@@ -209,7 +225,8 @@ export class Router {
|
|
|
209
225
|
const id = await this.addIntercept(patterns);
|
|
210
226
|
const previous = this.currentId;
|
|
211
227
|
this.currentId = id;
|
|
212
|
-
this.currentKey =
|
|
228
|
+
this.currentKey =
|
|
229
|
+
patterns && !patternsUnsupported.has(this.connector) ? key : "all";
|
|
213
230
|
this.interceptIds.add(id);
|
|
214
231
|
// The new intercept is in place before the old one goes: nothing slips through in between
|
|
215
232
|
if (previous !== undefined)
|
|
@@ -243,7 +260,9 @@ export class Router {
|
|
|
243
260
|
return intercept;
|
|
244
261
|
}
|
|
245
262
|
catch (err) {
|
|
246
|
-
if (patterns &&
|
|
263
|
+
if (patterns &&
|
|
264
|
+
err instanceof BiDiError &&
|
|
265
|
+
err.code === "invalid argument") {
|
|
247
266
|
patternsUnsupported.add(this.connector);
|
|
248
267
|
return this.addIntercept(undefined);
|
|
249
268
|
}
|
|
@@ -267,7 +286,9 @@ export class Router {
|
|
|
267
286
|
return;
|
|
268
287
|
this.connector.onEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
269
288
|
try {
|
|
270
|
-
this.subscription = await this.connector.subscribe([
|
|
289
|
+
this.subscription = await this.connector.subscribe([
|
|
290
|
+
"network.beforeRequestSent",
|
|
291
|
+
]);
|
|
271
292
|
}
|
|
272
293
|
catch (err) {
|
|
273
294
|
this.connector.offEvent("network.beforeRequestSent", this.onBeforeRequestSent);
|
|
@@ -296,6 +317,7 @@ export class Router {
|
|
|
296
317
|
settled: (settled) => {
|
|
297
318
|
this.pending.delete(settled);
|
|
298
319
|
},
|
|
320
|
+
sendsContentLength: this.connector.browserName !== "chrome",
|
|
299
321
|
};
|
|
300
322
|
if (!entry) {
|
|
301
323
|
// Nobody (left) to ask: an unmatched request, or every route fell back
|
package/dist/recorder/session.js
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Browser } from "../browser/browser.js";
|
|
4
4
|
import { ensureWarm } from "../browser/warm-up.js";
|
|
5
|
-
import { loadConfig, projectDir } from "../config/config.js";
|
|
5
|
+
import { loadConfig, projectDir, readConfig } from "../config/config.js";
|
|
6
6
|
import { loadSecrets } from "../config/secrets.js";
|
|
7
7
|
import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
|
|
8
8
|
import { withProject } from "../runner/project.js";
|
|
@@ -101,7 +101,8 @@ export async function recordSpec(options) {
|
|
|
101
101
|
throw new Error(`${options.file} does not exist; give a title with --new to create it`);
|
|
102
102
|
}
|
|
103
103
|
// Before the spec is touched: a recording without its browser changes nothing
|
|
104
|
-
await
|
|
104
|
+
const browserName = (await readConfig("browser")) ?? "firefox";
|
|
105
|
+
await ensureWarm(browserName, { port: options.port ?? 9223 }, options.signal);
|
|
105
106
|
options.signal?.throwIfAborted();
|
|
106
107
|
source ??= "";
|
|
107
108
|
if (options.create !== undefined &&
|
|
@@ -117,7 +118,7 @@ export async function recordSpec(options) {
|
|
|
117
118
|
if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
|
|
118
119
|
throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
|
|
119
120
|
}
|
|
120
|
-
const { browser, page } = await Browser.launch(
|
|
121
|
+
const { browser, page } = await Browser.launch(browserName, {
|
|
121
122
|
port: options.port ?? 9223,
|
|
122
123
|
headless: options.headless ?? settings.headless,
|
|
123
124
|
}, options.signal);
|
|
@@ -46,11 +46,6 @@ export interface ControllerOptions {
|
|
|
46
46
|
type Message = ToolbarMessage & {
|
|
47
47
|
target?: ElementHandle;
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
50
|
-
* The recorder's side of the toolbar: owns its state (pause, the rail, the environment's variables and
|
|
51
|
-
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
52
|
-
* their names and whether they are set.
|
|
53
|
-
*/
|
|
54
49
|
export declare class ToolbarController {
|
|
55
50
|
private readonly deps;
|
|
56
51
|
private readonly options;
|
|
@@ -79,6 +74,8 @@ export declare class ToolbarController {
|
|
|
79
74
|
private readonly done;
|
|
80
75
|
/** Bumped on every page load: work started for an older page is not answered */
|
|
81
76
|
private page;
|
|
77
|
+
/** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
|
|
78
|
+
private pageGone;
|
|
82
79
|
private toolbar;
|
|
83
80
|
/** A `ready` arrived since the last load: it counts for the next one */
|
|
84
81
|
private readySinceLoad;
|
|
@@ -104,6 +101,10 @@ export declare class ToolbarController {
|
|
|
104
101
|
* and doesn't count for the coming load
|
|
105
102
|
*/
|
|
106
103
|
navigating(): void;
|
|
104
|
+
/** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
|
|
105
|
+
private leavePage;
|
|
106
|
+
/** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
|
|
107
|
+
private whileOnPage;
|
|
107
108
|
/** A step was emitted (any source): count it */
|
|
108
109
|
stepAdded(step: Step): void;
|
|
109
110
|
/** A recorded step was taken out again (a cancelled file picker's click) */
|
|
@@ -22,6 +22,8 @@ const fail = (error, field) => ({
|
|
|
22
22
|
* secrets) and turns its messages into observations. Secret values stay here; the page only ever gets
|
|
23
23
|
* their names and whether they are set.
|
|
24
24
|
*/
|
|
25
|
+
/** What a wait on the page ends with when the page was left first */
|
|
26
|
+
const LEFT = Symbol("left the page");
|
|
25
27
|
export class ToolbarController {
|
|
26
28
|
constructor(deps, options) {
|
|
27
29
|
this.deps = deps;
|
|
@@ -41,6 +43,8 @@ export class ToolbarController {
|
|
|
41
43
|
this.done = new Set();
|
|
42
44
|
/** Bumped on every page load: work started for an older page is not answered */
|
|
43
45
|
this.page = 0;
|
|
46
|
+
/** Wake-ups for the work that is waiting on the current page: called, and forgotten, when the page changes */
|
|
47
|
+
this.pageGone = new Set();
|
|
44
48
|
this.toolbar = true;
|
|
45
49
|
/** A `ready` arrived since the last load: it counts for the next one */
|
|
46
50
|
this.readySinceLoad = false;
|
|
@@ -173,6 +177,7 @@ export class ToolbarController {
|
|
|
173
177
|
if (this.disposed)
|
|
174
178
|
return;
|
|
175
179
|
this.page++;
|
|
180
|
+
this.leavePage();
|
|
176
181
|
this.pick = undefined;
|
|
177
182
|
this.toast = undefined;
|
|
178
183
|
this.push();
|
|
@@ -197,6 +202,28 @@ export class ToolbarController {
|
|
|
197
202
|
*/
|
|
198
203
|
navigating() {
|
|
199
204
|
this.readySinceLoad = false;
|
|
205
|
+
this.leavePage();
|
|
206
|
+
}
|
|
207
|
+
/** Lets what waits on the current document stop waiting: its commands may never be answered once it is gone */
|
|
208
|
+
leavePage() {
|
|
209
|
+
const waiting = [...this.pageGone];
|
|
210
|
+
this.pageGone.clear();
|
|
211
|
+
for (const wake of waiting)
|
|
212
|
+
wake();
|
|
213
|
+
}
|
|
214
|
+
/** Runs `work`, but stops waiting for it when the page is left; resolves `gone` then */
|
|
215
|
+
async whileOnPage(work, gone) {
|
|
216
|
+
let wake;
|
|
217
|
+
const left = new Promise((resolve) => {
|
|
218
|
+
wake = () => resolve(gone);
|
|
219
|
+
this.pageGone.add(wake);
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
return await Promise.race([work(), left]);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
this.pageGone.delete(wake);
|
|
226
|
+
}
|
|
200
227
|
}
|
|
201
228
|
/** A step was emitted (any source): count it */
|
|
202
229
|
stepAdded(step) {
|
|
@@ -310,19 +337,24 @@ export class ToolbarController {
|
|
|
310
337
|
return this.reply(message.id, fail(PAUSED));
|
|
311
338
|
let found;
|
|
312
339
|
try {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
340
|
+
// Chrome never answers a command into a document that is being replaced, so a pick of the old page must not
|
|
341
|
+
// keep the messages behind it waiting
|
|
342
|
+
found = await this.whileOnPage(async () => {
|
|
343
|
+
const locator = message.target &&
|
|
344
|
+
(await this.deps.rank(message.target, message.candidates));
|
|
345
|
+
if (!locator)
|
|
346
|
+
return undefined;
|
|
347
|
+
const inspected = await this.deps.inspect(message.target, locator, message.candidates);
|
|
317
348
|
if (page === this.page)
|
|
318
|
-
this.pick = { id: message.id, locator, counts:
|
|
319
|
-
|
|
349
|
+
this.pick = { id: message.id, locator, counts: inspected.counts };
|
|
350
|
+
return inspected;
|
|
351
|
+
}, LEFT);
|
|
320
352
|
}
|
|
321
353
|
catch {
|
|
322
354
|
found = undefined; // the element went away while it was inspected
|
|
323
355
|
}
|
|
324
356
|
// The page that asked is gone: its successor's toolbar isn't waiting for this
|
|
325
|
-
if (page !== this.page)
|
|
357
|
+
if (found === LEFT || page !== this.page)
|
|
326
358
|
return;
|
|
327
359
|
this.reply(message.id, found ? this.withoutSecrets(found.info) : fail(UNIDENTIFIABLE));
|
|
328
360
|
}
|
package/dist/script/preload.js
CHANGED
|
@@ -16,11 +16,24 @@ export async function addPreload(connector, options) {
|
|
|
16
16
|
arguments: options.arguments.map((arg) => toLocalValue(arg)),
|
|
17
17
|
}),
|
|
18
18
|
});
|
|
19
|
+
let stopRerun;
|
|
20
|
+
if (needsRerun(connector, options)) {
|
|
21
|
+
try {
|
|
22
|
+
stopRerun = await rerunInNewRealms(connector, options);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
await connector
|
|
26
|
+
.send("script.removePreloadScript", { script })
|
|
27
|
+
.catch(() => undefined);
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
19
31
|
let removed = false;
|
|
20
32
|
const dispose = async () => {
|
|
21
33
|
if (removed)
|
|
22
34
|
return;
|
|
23
35
|
removed = true;
|
|
36
|
+
await stopRerun?.();
|
|
24
37
|
try {
|
|
25
38
|
await connector.send("script.removePreloadScript", { script });
|
|
26
39
|
}
|
|
@@ -42,6 +55,58 @@ export async function addPreload(connector, options) {
|
|
|
42
55
|
}
|
|
43
56
|
return { id: script, dispose };
|
|
44
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Chrome drops the messages of a channel that a preload script in a sandbox was given, while a channel handed to a
|
|
60
|
+
* call into the same sandbox works. So there the script is also run, with its channels, in every new sandbox realm.
|
|
61
|
+
*/
|
|
62
|
+
function needsRerun(connector, options) {
|
|
63
|
+
return (connector.browserName === "chrome" &&
|
|
64
|
+
options.sandbox !== undefined &&
|
|
65
|
+
(options.arguments?.length ?? 0) > 0);
|
|
66
|
+
}
|
|
67
|
+
async function rerunInNewRealms(connector, options) {
|
|
68
|
+
const { contexts, sandbox, source } = options;
|
|
69
|
+
const inScope = async (context) => {
|
|
70
|
+
if (!contexts)
|
|
71
|
+
return true;
|
|
72
|
+
if (contexts.includes(context))
|
|
73
|
+
return true;
|
|
74
|
+
const trees = await Promise.all(contexts.map((root) => connector.send("browsingContext.getTree", { root })));
|
|
75
|
+
return trees.some((tree) => tree.contexts.flatMap(flatten).includes(context));
|
|
76
|
+
};
|
|
77
|
+
const onRealm = (realm) => {
|
|
78
|
+
if (realm.type !== "window" || realm.sandbox !== sandbox || !realm.context)
|
|
79
|
+
return;
|
|
80
|
+
const context = realm.context;
|
|
81
|
+
void (async () => {
|
|
82
|
+
try {
|
|
83
|
+
if (!(await inScope(context)))
|
|
84
|
+
return;
|
|
85
|
+
await callFunction(connector, context, source, options.arguments ?? [], {
|
|
86
|
+
awaitPromise: false,
|
|
87
|
+
...(sandbox !== undefined && { sandbox }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// The document went away again, or the script threw: the registration covers what it can
|
|
92
|
+
logger.debug("Could not run a preload script in a new realm", { err });
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
};
|
|
96
|
+
connector.onEvent("script.realmCreated", onRealm);
|
|
97
|
+
let subscription;
|
|
98
|
+
try {
|
|
99
|
+
subscription = await connector.subscribe(["script.realmCreated"]);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
connector.offEvent("script.realmCreated", onRealm);
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
return async () => {
|
|
106
|
+
connector.offEvent("script.realmCreated", onRealm);
|
|
107
|
+
await subscription.unsubscribe().catch(() => undefined);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
45
110
|
/** Runs `source` once in every loaded context (iframes included) under `contexts` */
|
|
46
111
|
export async function runInLoadedContexts(connector, { source, contexts, sandbox, onRunError = "throw", arguments: args = [], }) {
|
|
47
112
|
const trees = contexts
|
|
@@ -25,12 +25,18 @@ export declare class BiDiConnector {
|
|
|
25
25
|
private currentId;
|
|
26
26
|
/** Set once the socket closed or failed: nothing sent after that gets an answer */
|
|
27
27
|
private closed;
|
|
28
|
+
/** Which browser is on the other end, when the launcher says; code that must treat browsers apart asks this */
|
|
29
|
+
browserName?: "firefox" | "chrome";
|
|
28
30
|
private webSocket;
|
|
29
31
|
private eventEmitter;
|
|
30
32
|
private commandTimeout;
|
|
31
33
|
private subscriptions;
|
|
32
34
|
constructor(ws: WebSocket, options?: ConnectorOptions);
|
|
33
35
|
static connect(url: string, options?: ConnectorOptions): Promise<BiDiConnector>;
|
|
36
|
+
/** Calls `listener` once when the socket closes or fails (not when `kill()` ends it) */
|
|
37
|
+
onClosed(listener: () => void): void;
|
|
38
|
+
private closeListeners;
|
|
39
|
+
private notifyClosed;
|
|
34
40
|
private onWebsocketClose;
|
|
35
41
|
private onWebsocketError;
|
|
36
42
|
private get isClosed();
|
|
@@ -22,12 +22,15 @@ export class BiDiConnector {
|
|
|
22
22
|
this.currentId = 0;
|
|
23
23
|
/** Set once the socket closed or failed: nothing sent after that gets an answer */
|
|
24
24
|
this.closed = false;
|
|
25
|
+
this.closeListeners = new Set();
|
|
25
26
|
this.onWebsocketClose = () => {
|
|
26
27
|
this.closed = true;
|
|
28
|
+
this.notifyClosed();
|
|
27
29
|
this.rejectAll((pending) => new ConnectionClosedError(`Websocket closed${pendingSuffix(pending)}`));
|
|
28
30
|
};
|
|
29
31
|
this.onWebsocketError = (ev) => {
|
|
30
32
|
this.closed = true;
|
|
33
|
+
this.notifyClosed();
|
|
31
34
|
this.rejectAll((pending) => new ConnectionClosedError(`Websocket error${pendingSuffix(pending)}`, {
|
|
32
35
|
cause: ev,
|
|
33
36
|
}));
|
|
@@ -84,6 +87,16 @@ export class BiDiConnector {
|
|
|
84
87
|
});
|
|
85
88
|
});
|
|
86
89
|
}
|
|
90
|
+
/** Calls `listener` once when the socket closes or fails (not when `kill()` ends it) */
|
|
91
|
+
onClosed(listener) {
|
|
92
|
+
this.closeListeners.add(listener);
|
|
93
|
+
}
|
|
94
|
+
notifyClosed() {
|
|
95
|
+
const listeners = [...this.closeListeners];
|
|
96
|
+
this.closeListeners.clear();
|
|
97
|
+
for (const listener of listeners)
|
|
98
|
+
listener();
|
|
99
|
+
}
|
|
87
100
|
get isClosed() {
|
|
88
101
|
// A socket that is closing or closed (readyState 2 or 3) has already lost the browser
|
|
89
102
|
const state = this.webSocket.readyState;
|
package/dist/types/browser.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export type SupportedBrowser = "firefox";
|
|
1
|
+
export type SupportedBrowser = "firefox" | "chrome";
|
package/dist/types/config.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface SamuraiTestConfig {
|
|
|
45
45
|
*/
|
|
46
46
|
srcDir?: string;
|
|
47
47
|
/**
|
|
48
|
-
* The browser to use for all tests
|
|
48
|
+
* The browser to use for all tests: "firefox" or "chrome"
|
|
49
49
|
* @default "firefox"
|
|
50
50
|
*/
|
|
51
51
|
browser?: SupportedBrowser;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itmetsam/samurai-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
|
|
5
5
|
"main": "dist/api.js",
|
|
6
6
|
"exports": {
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
"dev": "tsx src/index.ts",
|
|
55
55
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
|
|
56
56
|
"test": "tsx --test --test-timeout=10000 \"src/**/*.test.ts\"",
|
|
57
|
-
"test:browser": "tsx --test --test-concurrency=1 \"src/**/*.browser-test.ts\"",
|
|
57
|
+
"test:browser": "tsx --test --test-concurrency=1 --test-timeout=60000 \"src/**/*.browser-test.ts\"",
|
|
58
|
+
"test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",
|
|
58
59
|
"test:cli": "tsx --test --test-concurrency=1 \"src/**/*.cli-test.ts\""
|
|
59
60
|
},
|
|
60
61
|
"keywords": [
|