@itmetsam/samurai-framework 0.8.0 → 0.10.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 (66) hide show
  1. package/README.md +9 -9
  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 +69 -0
  6. package/dist/browser/chrome-launch.js +170 -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/cli/args.d.ts +7 -2
  27. package/dist/cli/args.js +7 -1
  28. package/dist/cli/console-reporter.d.ts +4 -0
  29. package/dist/cli/console-reporter.js +13 -6
  30. package/dist/cli/init.js +1 -1
  31. package/dist/cli/main.d.ts +1 -1
  32. package/dist/cli/main.js +7 -0
  33. package/dist/config/config.js +5 -1
  34. package/dist/flows/run/handlers/support.d.ts +4 -1
  35. package/dist/flows/run/handlers/support.js +1 -1
  36. package/dist/flows/run/project.js +9 -1
  37. package/dist/flows/run/run.d.ts +5 -0
  38. package/dist/flows/run/run.js +7 -1
  39. package/dist/locator/locator.js +21 -2
  40. package/dist/network/data-collector.d.ts +1 -0
  41. package/dist/network/data-collector.js +23 -5
  42. package/dist/network/network-tracker.d.ts +8 -1
  43. package/dist/network/network-tracker.js +42 -3
  44. package/dist/network/router.d.ts +5 -0
  45. package/dist/network/router.js +33 -11
  46. package/dist/recorder/session.d.ts +3 -0
  47. package/dist/recorder/session.js +7 -3
  48. package/dist/recorder/toolbar/controller.d.ts +6 -5
  49. package/dist/recorder/toolbar/controller.js +39 -7
  50. package/dist/runner/browsers.d.ts +22 -0
  51. package/dist/runner/browsers.js +44 -0
  52. package/dist/runner/groups.d.ts +5 -1
  53. package/dist/runner/groups.js +9 -2
  54. package/dist/runner/reporter.d.ts +10 -2
  55. package/dist/runner/reporter.js +30 -10
  56. package/dist/runner/run.d.ts +1 -0
  57. package/dist/runner/run.js +1 -0
  58. package/dist/runner/test-runner.d.ts +9 -1
  59. package/dist/runner/test-runner.js +46 -19
  60. package/dist/script/preload.js +65 -0
  61. package/dist/transport/bidi-connection.d.ts +19 -0
  62. package/dist/transport/bidi-connection.js +34 -1
  63. package/dist/types/browser.d.ts +1 -1
  64. package/dist/types/config.d.ts +5 -3
  65. package/dist/types/test.d.ts +8 -0
  66. package/package.json +3 -2
@@ -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
@@ -1,5 +1,6 @@
1
1
  import { Browser } from "../browser/browser.js";
2
2
  import type Page from "../browser/page.js";
3
+ import type { SupportedBrowser } from "../types/browser.js";
3
4
  import type { PreparedRun } from "../runner/prepare-run.js";
4
5
  import { type ProjectOptions } from "../runner/project.js";
5
6
  import type { SamuraiTestConfig } from "../types/config.js";
@@ -20,6 +21,8 @@ export interface RecordSpecOptions extends ProjectOptions {
20
21
  at?: number;
21
22
  /** The page to start on. @default the environment's base URL; without one the browser starts blank */
22
23
  url?: string;
24
+ /** The browser to record in. @default the first of the config's `browsers`, else Firefox */
25
+ browser?: SupportedBrowser;
23
26
  /** The browser's debugging port. @default 9223 */
24
27
  port?: number;
25
28
  /** Run the browser without a window. A person can't use it then; for tests and machines without a display. @default the config's `use.headless`, else headless on CI and a window otherwise */
@@ -2,7 +2,8 @@ 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 { browsersFor } from "../runner/browsers.js";
6
+ import { loadConfig, projectDir, readConfig } from "../config/config.js";
6
7
  import { loadSecrets } from "../config/secrets.js";
7
8
  import { filesFolder, isFolderIgnored, isGitIgnored, } from "../project/index.js";
8
9
  import { withProject } from "../runner/project.js";
@@ -101,7 +102,10 @@ export async function recordSpec(options) {
101
102
  throw new Error(`${options.file} does not exist; give a title with --new to create it`);
102
103
  }
103
104
  // Before the spec is touched: a recording without its browser changes nothing
104
- await ensureWarm("firefox", { port: options.port ?? 9223 }, options.signal);
105
+ // Recording in two browsers at once makes no sense: the first of the project's, or the one asked for
106
+ const browserName = options.browser ??
107
+ browsersFor({ project: await readConfig("browsers") })[0];
108
+ await ensureWarm(browserName, { port: options.port ?? 9223 }, options.signal);
105
109
  options.signal?.throwIfAborted();
106
110
  source ??= "";
107
111
  if (options.create !== undefined &&
@@ -117,7 +121,7 @@ export async function recordSpec(options) {
117
121
  if (!Number.isInteger(at) || at < 0 || at > test.steps.length) {
118
122
  throw new RangeError(`--at must be between 0 and ${test.steps.length} (the test has ${test.steps.length} steps)`);
119
123
  }
120
- const { browser, page } = await Browser.launch("firefox", {
124
+ const { browser, page } = await Browser.launch(browserName, {
121
125
  port: options.port ?? 9223,
122
126
  headless: options.headless ?? settings.headless,
123
127
  }, 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
  }
@@ -0,0 +1,22 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
2
+ import type { SamuraiTestConfig } from "../types/config.js";
3
+ /** Every browser a config may name, in the order the messages list them */
4
+ export declare const BROWSER_NAMES: readonly SupportedBrowser[];
5
+ /** The browsers of a run that names none */
6
+ export declare const DEFAULT_BROWSERS: readonly SupportedBrowser[];
7
+ /** Throws when `list` is not a non-empty list of distinct browser names; `where` names the key in the message */
8
+ export declare function checkBrowserList(list: unknown, where: string): void;
9
+ /**
10
+ * Checks the browser keys of a config: the removed `browser`, and `browsers` on the project and on each group.
11
+ * Throws an Error that says what to write instead.
12
+ */
13
+ export declare function validateBrowserConfig(config: SamuraiTestConfig): void;
14
+ /**
15
+ * The browsers a run uses, the first that is set winning: the command line, the group being run, the project, then
16
+ * Firefox. An empty list counts as not given.
17
+ */
18
+ export declare function browsersFor(sources: {
19
+ flag?: readonly SupportedBrowser[] | undefined;
20
+ group?: readonly SupportedBrowser[] | undefined;
21
+ project?: readonly SupportedBrowser[] | undefined;
22
+ }): SupportedBrowser[];
@@ -0,0 +1,44 @@
1
+ /** Every browser a config may name, in the order the messages list them */
2
+ export const BROWSER_NAMES = ["firefox", "chrome"];
3
+ /** The browsers of a run that names none */
4
+ export const DEFAULT_BROWSERS = ["firefox"];
5
+ const isBrowser = (value) => typeof value === "string" &&
6
+ BROWSER_NAMES.includes(value);
7
+ /** Throws when `list` is not a non-empty list of distinct browser names; `where` names the key in the message */
8
+ export function checkBrowserList(list, where) {
9
+ if (!Array.isArray(list) || list.length === 0)
10
+ throw new Error(`${where} must list at least one browser.`);
11
+ const seen = new Set();
12
+ for (const name of list) {
13
+ if (!isBrowser(name))
14
+ throw new Error(`Unknown browser "${String(name)}" in ${where}. Choose ${BROWSER_NAMES.join(" or ")}.`);
15
+ if (seen.has(name))
16
+ throw new Error(`Browser "${name}" is listed twice in ${where}.`);
17
+ seen.add(name);
18
+ }
19
+ }
20
+ /**
21
+ * Checks the browser keys of a config: the removed `browser`, and `browsers` on the project and on each group.
22
+ * Throws an Error that says what to write instead.
23
+ */
24
+ export function validateBrowserConfig(config) {
25
+ const legacy = config.browser;
26
+ if (legacy !== undefined)
27
+ throw new Error(`"browser" was replaced by "browsers": use browsers: [${JSON.stringify(legacy)}]`);
28
+ if (config.browsers !== undefined)
29
+ checkBrowserList(config.browsers, "browsers");
30
+ config.groups?.forEach((group, index) => {
31
+ if (group.browsers !== undefined)
32
+ checkBrowserList(group.browsers, `groups[${index}].browsers`);
33
+ });
34
+ }
35
+ /**
36
+ * The browsers a run uses, the first that is set winning: the command line, the group being run, the project, then
37
+ * Firefox. An empty list counts as not given.
38
+ */
39
+ export function browsersFor(sources) {
40
+ for (const list of [sources.flag, sources.group, sources.project])
41
+ if (list && list.length > 0)
42
+ return [...list];
43
+ return [...DEFAULT_BROWSERS];
44
+ }
@@ -1,3 +1,4 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
1
2
  import type { SamuraiGroup } from "../types/config.js";
2
3
  /** The test folder when the config names none */
3
4
  export declare const DEFAULT_SRC_DIR = "./src";
@@ -22,6 +23,8 @@ export interface ResolvedGroup {
22
23
  file: string;
23
24
  title: string;
24
25
  }[];
26
+ /** The browsers the group's tests run in: its own `browsers`, else the project's, else Firefox */
27
+ browsers: SupportedBrowser[];
25
28
  }
26
29
  /**
27
30
  * The real path of `p` (symlinks resolved), or `p` itself when it does not exist. Registered test files come
@@ -33,10 +36,11 @@ export declare const toPosix: (p: string) => string;
33
36
  /** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
34
37
  export declare const testId: (file: string, name: string) => string;
35
38
  /** Works out which of `tests` (files relative to `srcDir`) belong to the group */
36
- export declare function resolveGroup(group: SamuraiGroup, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup;
39
+ export declare function resolveGroup(group: SamuraiGroup, tests: GroupTest[], projectDir: string, srcDir: string, projectBrowsers?: readonly SupportedBrowser[]): ResolvedGroup;
37
40
  /** Resolves every group of the config */
38
41
  export declare function resolveGroups(config: {
39
42
  groups?: SamuraiGroup[];
43
+ browsers?: SupportedBrowser[];
40
44
  }, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup[];
41
45
  /** The group called `name`, or an error naming the groups there are */
42
46
  export declare function findGroup<T extends {
@@ -1,5 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { matchesGlob, relative, resolve } from "node:path";
3
+ import { browsersFor } from "./browsers.js";
3
4
  /** The test folder when the config names none */
4
5
  export const DEFAULT_SRC_DIR = "./src";
5
6
  /** The spec pattern when a group names none */
@@ -21,7 +22,11 @@ export const toPosix = (p) => p.replace(/\\/g, "/");
21
22
  /** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
22
23
  export const testId = (file, name) => `${toPosix(file)}::${name}`;
23
24
  /** Works out which of `tests` (files relative to `srcDir`) belong to the group */
24
- export function resolveGroup(group, tests, projectDir, srcDir) {
25
+ export function resolveGroup(group, tests, projectDir, srcDir, projectBrowsers) {
26
+ const browsers = browsersFor({
27
+ group: group.browsers,
28
+ project: projectBrowsers,
29
+ });
25
30
  const found = tests.map((t) => ({ ...t, id: testId(t.file, t.name) }));
26
31
  // Tests picked by hand override src and testMatch
27
32
  if (group.tests) {
@@ -38,6 +43,7 @@ export function resolveGroup(group, tests, projectDir, srcDir) {
38
43
  missing: picked
39
44
  .filter((t) => !known.has(t.id))
40
45
  .map(({ file, title }) => ({ file, title })),
46
+ browsers,
41
47
  };
42
48
  }
43
49
  const base = group.src ? resolve(projectDir, group.src) : srcDir;
@@ -54,11 +60,12 @@ export function resolveGroup(group, tests, projectDir, srcDir) {
54
60
  pattern: [group.src, group.testMatch].filter(Boolean).join(" · ") || pattern,
55
61
  testIds,
56
62
  missing: [],
63
+ browsers,
57
64
  };
58
65
  }
59
66
  /** Resolves every group of the config */
60
67
  export function resolveGroups(config, tests, projectDir, srcDir) {
61
- return (config.groups ?? []).map((group) => resolveGroup(group, tests, projectDir, srcDir));
68
+ return (config.groups ?? []).map((group) => resolveGroup(group, tests, projectDir, srcDir, config.browsers));
62
69
  }
63
70
  /** The group called `name`, or an error naming the groups there are */
64
71
  export function findGroup(groups, name) {
@@ -1,17 +1,22 @@
1
1
  import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestSummary } from "../types/test.js";
2
+ import type { SupportedBrowser } from "../types/browser.js";
2
3
  /** What a run reports while it happens */
3
4
  export type RunEvent = {
4
5
  type: "run-start";
5
6
  environment: string;
7
+ /** Every selected test in every browser */
6
8
  total: number;
9
+ browsers: SupportedBrowser[];
7
10
  } | {
8
11
  type: "test-start";
9
12
  name: string;
10
13
  file: string;
14
+ browser: SupportedBrowser;
11
15
  } | {
12
16
  type: "test-end";
13
17
  name: string;
14
18
  file: string;
19
+ browser: SupportedBrowser;
15
20
  result: TestResult;
16
21
  } | {
17
22
  type: "run-end";
@@ -20,6 +25,8 @@ export type RunEvent = {
20
25
  export interface ReporterOptions {
21
26
  /** The environment the run uses, recorded in the report */
22
27
  environment: string;
28
+ /** The browsers the run uses, in the order they run; recorded in the report */
29
+ browsers: SupportedBrowser[];
23
30
  /** Where the report is written. @default <cwd>/result/report.json */
24
31
  output?: string | false;
25
32
  /** Called with every event, in order. A throwing listener is ignored */
@@ -29,14 +36,15 @@ export default class TestReporter {
29
36
  private testResults;
30
37
  private summary;
31
38
  private environment;
39
+ private browsers;
32
40
  private output;
33
41
  private listener;
34
42
  constructor(options: ReporterOptions);
35
43
  private emit;
36
44
  onStart(total?: number): void;
37
45
  onEnd(): Promise<TestSummary>;
38
- onTestStart(test: RegisteredTestCase): void;
39
- onTestEnd(test: RegisteredTestCase, error?: TestError, extras?: {
46
+ onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
47
+ onTestEnd(test: RegisteredTestCase, browser: SupportedBrowser, error?: TestError, extras?: {
40
48
  logs?: TestLogEntry[];
41
49
  logsDropped?: number;
42
50
  screenshots?: {
@@ -2,14 +2,15 @@ import { mkdir, writeFile } from "fs/promises";
2
2
  import path from "path";
3
3
  import { projectDir } from "../config/config.js";
4
4
  import { maskDeep } from "../config/mask.js";
5
- /** Results are keyed by file and full title, so equal titles in different files or describe blocks stay apart */
6
- function keyOf(test) {
7
- return `${test.file}::${test.name}`;
5
+ /** Results are keyed by file, full title and browser, so equal titles in different files or describe blocks, and the same test in two browsers, stay apart */
6
+ function keyOf(test, browser) {
7
+ return `${test.file}::${test.name}::${browser}`;
8
8
  }
9
9
  export default class TestReporter {
10
10
  constructor(options) {
11
11
  this.testResults = new Map();
12
12
  this.environment = options.environment;
13
+ this.browsers = [...options.browsers];
13
14
  this.output =
14
15
  options.output ?? path.join(projectDir(), "result/report.json");
15
16
  this.listener = options.onEvent;
@@ -29,9 +30,15 @@ export default class TestReporter {
29
30
  status: "failed",
30
31
  startTime: t.startTime,
31
32
  environment: this.environment,
33
+ browsers: this.browsers,
32
34
  tests: [],
33
35
  };
34
- this.emit({ type: "run-start", environment: this.environment, total });
36
+ this.emit({
37
+ type: "run-start",
38
+ environment: this.environment,
39
+ total,
40
+ browsers: this.browsers,
41
+ });
35
42
  }
36
43
  async onEnd() {
37
44
  performance.mark(`group-finish`);
@@ -40,6 +47,7 @@ export default class TestReporter {
40
47
  ...this.summary,
41
48
  duration,
42
49
  environment: this.environment,
50
+ browsers: this.browsers,
43
51
  tests: Array.from(this.testResults.values()),
44
52
  status: Array.from(this.testResults.values()).every((r) => r.status === "success")
45
53
  ? "success"
@@ -54,19 +62,25 @@ export default class TestReporter {
54
62
  this.emit({ type: "run-end", summary: this.summary });
55
63
  return this.summary;
56
64
  }
57
- onTestStart(test) {
58
- const key = keyOf(test);
65
+ onTestStart(test, browser) {
66
+ const key = keyOf(test, browser);
59
67
  const t = performance.mark(`${key}-start`);
60
68
  this.testResults.set(key, {
61
69
  name: test.name,
62
70
  file: test.file,
71
+ browser,
63
72
  status: "started",
64
73
  startTime: t.startTime,
65
74
  });
66
- this.emit({ type: "test-start", name: test.name, file: test.file });
75
+ this.emit({
76
+ type: "test-start",
77
+ name: test.name,
78
+ file: test.file,
79
+ browser,
80
+ });
67
81
  }
68
- onTestEnd(test, error, extras) {
69
- const key = keyOf(test);
82
+ onTestEnd(test, browser, error, extras) {
83
+ const key = keyOf(test, browser);
70
84
  performance.mark(`${key}-finish`);
71
85
  const duration = performance.measure("test-duration", `${key}-start`, `${key}-finish`).duration;
72
86
  const result = maskDeep({
@@ -79,6 +93,12 @@ export default class TestReporter {
79
93
  ...(extras?.screenshots?.length && { screenshots: extras.screenshots }),
80
94
  });
81
95
  this.testResults.set(key, result);
82
- this.emit({ type: "test-end", name: test.name, file: test.file, result });
96
+ this.emit({
97
+ type: "test-end",
98
+ name: test.name,
99
+ file: test.file,
100
+ browser,
101
+ result,
102
+ });
83
103
  }
84
104
  }
@@ -24,6 +24,7 @@ export interface FoundTest {
24
24
  export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
25
25
  /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
26
26
  export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
27
+ export { BROWSER_NAMES, checkBrowserList } from "./browsers.js";
27
28
  export type { RunEvent } from "./reporter.js";
28
29
  export type { TestError, TestResult, TestSummary } from "../types/test.js";
29
30
  export type { ResolvedGroup } from "./groups.js";
@@ -44,3 +44,4 @@ export async function listGroups(options = {}) {
44
44
  expectTimeout,
45
45
  }, async (run) => (await TestRunner.init(run, undefined, runner)).groups());
46
46
  }
47
+ export { BROWSER_NAMES, checkBrowserList } from "./browsers.js";
@@ -1,3 +1,4 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
1
2
  import type { RegisteredTestCase } from "../types/test.js";
2
3
  import type { PreparedRun } from "./prepare-run.js";
3
4
  import { type RunEvent } from "./reporter.js";
@@ -13,6 +14,8 @@ export interface RunnerOptions {
13
14
  testNames?: string[];
14
15
  /** Only the tests of this group of the config's `groups` (unknown name: an error). Combines with the other filters */
15
16
  group?: string;
17
+ /** The browsers to run in, instead of the group's or the project's `browsers` (the command line's `--browser`) */
18
+ browsers?: SupportedBrowser[];
16
19
  /** Run the browser without a window. @default false */
17
20
  headless?: boolean;
18
21
  /** Remote debugging port of the browser. @default 9223 */
@@ -49,7 +52,12 @@ export default class TestRunner {
49
52
  groups(): Promise<ResolvedGroup[]>;
50
53
  /** Imports the spec files and returns the tests the options select: by `group`, `testNames` and `grep` */
51
54
  register(): Promise<RegisteredTestCase[]>;
52
- /** Runs the selected tests and returns the summary that was also written to the report */
55
+ /**
56
+ * The browsers the run uses: the options' (`--browser`), else the selected group's, else the project's, else
57
+ * Firefox. A group is only looked up when there is something to look up
58
+ */
59
+ private browsersOfRun;
60
+ /** Runs the selected tests in every browser of the run, one browser after the other, and returns the summary that was also written to the report */
53
61
  start(): Promise<import("../types/test.js").TestSummary>;
54
62
  private executeTestCase;
55
63
  }