@itmetsam/samurai-framework 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +8 -8
  2. package/dist/browser/browser-finder.js +2 -2
  3. package/dist/browser/browser.d.ts +11 -15
  4. package/dist/browser/browser.js +79 -58
  5. package/dist/browser/chrome-launch.d.ts +57 -0
  6. package/dist/browser/chrome-launch.js +127 -0
  7. package/dist/browser/download-folders.d.ts +27 -0
  8. package/dist/browser/download-folders.js +97 -0
  9. package/dist/browser/page-logs.d.ts +2 -0
  10. package/dist/browser/page-logs.js +25 -5
  11. package/dist/browser/page.d.ts +18 -0
  12. package/dist/browser/page.js +99 -2
  13. package/dist/browser/screenshot.js +16 -2
  14. package/dist/browser/session-capabilities.d.ts +12 -0
  15. package/dist/browser/session-capabilities.js +8 -0
  16. package/dist/browsers/index.d.ts +2 -2
  17. package/dist/browsers/index.js +2 -2
  18. package/dist/browsers/overrides.d.ts +2 -0
  19. package/dist/browsers/overrides.js +2 -0
  20. package/dist/browsers/pins.d.ts +1 -1
  21. package/dist/browsers/pins.js +2 -2
  22. package/dist/browsers/resolve.d.ts +2 -0
  23. package/dist/browsers/resolve.js +14 -2
  24. package/dist/browsers/store.d.ts +2 -0
  25. package/dist/browsers/store.js +8 -0
  26. package/dist/locator/locator.js +21 -2
  27. package/dist/network/data-collector.d.ts +1 -0
  28. package/dist/network/data-collector.js +23 -5
  29. package/dist/network/network-tracker.d.ts +8 -1
  30. package/dist/network/network-tracker.js +42 -3
  31. package/dist/network/router.d.ts +5 -0
  32. package/dist/network/router.js +33 -11
  33. package/dist/recorder/session.js +4 -3
  34. package/dist/recorder/toolbar/controller.d.ts +6 -5
  35. package/dist/recorder/toolbar/controller.js +39 -7
  36. package/dist/script/preload.js +65 -0
  37. package/dist/transport/bidi-connection.d.ts +6 -0
  38. package/dist/transport/bidi-connection.js +13 -0
  39. package/dist/types/browser.d.ts +1 -1
  40. package/dist/types/config.d.ts +1 -1
  41. package/package.json +3 -2
@@ -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 = { reject: (reason) => finish(() => reject(reason)) };
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" ? Buffer.from(value.value, "base64").toString("utf8") : value.value;
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
  }
@@ -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`,
@@ -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]) => ({ name, value: { type: "string", 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 ?? (text !== undefined ? "application/json" : undefined);
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 = { ...withoutLength, "content-length": String(Buffer.byteLength(options.postData)) };
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 && { body: toBytes(options.postData) }),
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.filter((entry) => matchUrl(entry.pattern, info.url)).reverse();
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) && (handler === undefined || route.handler === handler));
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].filter((route) => removed.includes(route.entry)).map((route) => route.release()));
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 = patterns && !patternsUnsupported.has(this.connector) ? key : "all";
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 && err instanceof BiDiError && err.code === "invalid argument") {
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(["network.beforeRequestSent"]);
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
@@ -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 ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
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("firefox", {
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
- const locator = message.target &&
314
- (await this.deps.rank(message.target, message.candidates));
315
- if (locator) {
316
- found = await this.deps.inspect(message.target, locator, message.candidates);
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: found.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
  }
@@ -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;
@@ -1 +1 @@
1
- export type SupportedBrowser = "firefox";
1
+ export type SupportedBrowser = "firefox" | "chrome";
@@ -45,7 +45,7 @@ export interface SamuraiTestConfig {
45
45
  */
46
46
  srcDir?: string;
47
47
  /**
48
- * The browser to use for all tests. Only "firefox" can launch today
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.8.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": [