@itmetsam/samurai-framework 0.12.1 → 0.15.1

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 (69) hide show
  1. package/README.md +29 -29
  2. package/dist/api.d.ts +2 -2
  3. package/dist/api.js +1 -1
  4. package/dist/cli/args.d.ts +4 -1
  5. package/dist/cli/args.js +7 -2
  6. package/dist/cli/console-reporter.js +17 -4
  7. package/dist/cli/flow.js +5 -0
  8. package/dist/cli/main.js +2 -1
  9. package/dist/cli/usage.d.ts +1 -1
  10. package/dist/cli/usage.js +2 -0
  11. package/dist/config/config.js +6 -0
  12. package/dist/config/forbid-only.d.ts +5 -0
  13. package/dist/config/forbid-only.js +9 -0
  14. package/dist/config/retries.d.ts +8 -0
  15. package/dist/config/retries.js +19 -0
  16. package/dist/flows/run/node-pool.d.ts +9 -0
  17. package/dist/flows/run/node-pool.js +27 -0
  18. package/dist/flows/run/project-options.d.ts +44 -0
  19. package/dist/flows/run/project-options.js +8 -0
  20. package/dist/flows/run/project.d.ts +4 -40
  21. package/dist/flows/run/project.js +30 -38
  22. package/dist/logger/index.d.ts +6 -0
  23. package/dist/logger/index.js +47 -6
  24. package/dist/recorder/candidates.d.ts +2 -1
  25. package/dist/recorder/candidates.js +44 -1
  26. package/dist/recorder/capture.d.ts +4 -2
  27. package/dist/recorder/capture.js +12 -4
  28. package/dist/recorder/page-messages.d.ts +2 -0
  29. package/dist/recorder/ranking.d.ts +2 -0
  30. package/dist/recorder/ranking.js +8 -1
  31. package/dist/recorder/recorder.js +5 -2
  32. package/dist/runner/all-hooks.d.ts +26 -0
  33. package/dist/runner/all-hooks.js +61 -0
  34. package/dist/runner/focus.d.ts +5 -0
  35. package/dist/runner/focus.js +19 -0
  36. package/dist/runner/hooks.d.ts +24 -0
  37. package/dist/runner/hooks.js +79 -0
  38. package/dist/runner/options.d.ts +6 -0
  39. package/dist/runner/pooled-run.d.ts +7 -1
  40. package/dist/runner/pooled-run.js +43 -14
  41. package/dist/runner/project.js +5 -0
  42. package/dist/runner/registry.d.ts +35 -4
  43. package/dist/runner/registry.js +66 -11
  44. package/dist/runner/reporter.d.ts +14 -4
  45. package/dist/runner/reporter.js +29 -2
  46. package/dist/runner/retries-option.d.ts +10 -0
  47. package/dist/runner/retries-option.js +12 -0
  48. package/dist/runner/retry.d.ts +13 -0
  49. package/dist/runner/retry.js +43 -0
  50. package/dist/runner/run-test.d.ts +8 -1
  51. package/dist/runner/run-test.js +34 -2
  52. package/dist/runner/run.d.ts +5 -2
  53. package/dist/runner/run.js +6 -1
  54. package/dist/runner/skip.d.ts +10 -0
  55. package/dist/runner/skip.js +18 -0
  56. package/dist/runner/suite.d.ts +24 -0
  57. package/dist/runner/suite.js +73 -0
  58. package/dist/runner/test-error.js +4 -0
  59. package/dist/runner/test-runner.js +54 -20
  60. package/dist/runner/worker-pool.d.ts +9 -1
  61. package/dist/runner/worker-pool.js +35 -2
  62. package/dist/runner/worker-protocol.d.ts +3 -0
  63. package/dist/runner/worker-session.js +17 -4
  64. package/dist/runner/worker.js +3 -0
  65. package/dist/steps/model.d.ts +3 -0
  66. package/dist/steps/parse.js +32 -7
  67. package/dist/types/config.d.ts +15 -1
  68. package/dist/types/test.d.ts +55 -3
  69. package/package.json +5 -4
@@ -2,7 +2,8 @@ import path from "node:path";
2
2
  import { projectConfig } from "../../config/config.js";
3
3
  import { checkFlow, withKeys, } from "../core/index.js";
4
4
  import { DEFAULT_SRC_DIR, realPath, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
5
- import logger from "../../logger/index.js";
5
+ import logger, { keepLogFiles } from "../../logger/index.js";
6
+ import { resolveRetries } from "../../runner/retries-option.js";
6
7
  import { resolveWorkers } from "../../runner/workers-option.js";
7
8
  import { withProject } from "../../runner/project.js";
8
9
  import { browsersFor } from "../../runner/browsers.js";
@@ -12,17 +13,12 @@ import TestRunner from "../../runner/test-runner.js";
12
13
  import { WorkerPool } from "../../runner/worker-pool.js";
13
14
  import { processWorkerFactory } from "../../runner/worker-process.js";
14
15
  import { readFlow } from "./files.js";
16
+ import { itemsOf, runOnPool } from "./node-pool.js";
17
+ import { FlowCheckError, } from "./project-options.js";
15
18
  import { projectSteps } from "../steps/set.js";
16
19
  import { HANDLERS } from "./handlers/index.js";
17
20
  import { runFlow } from "./run.js";
18
- /** The flow has errors; nothing was run */
19
- export class FlowCheckError extends Error {
20
- constructor(problems) {
21
- super(`This flow can't run:\n${problems.map((p) => ` - ${p.message}`).join("\n")}`);
22
- this.problems = problems;
23
- this.name = "FlowCheckError";
24
- }
25
- }
21
+ export { FlowCheckError };
26
22
  /** Whether the project's config defines an environment of this name */
27
23
  function hasEnvironment(config, name) {
28
24
  return Object.prototype.hasOwnProperty.call(config.environments ?? {}, name);
@@ -36,28 +32,6 @@ export function chooseEnvironment(config, flow, option) {
36
32
  return flow.envDefault;
37
33
  return config.defaultEnvironment ?? names[0] ?? "default";
38
34
  }
39
- /** The work items of a node: each test in each browser, the browsers one after the other */
40
- function itemsOf(tests, browsers) {
41
- return browsers
42
- .flatMap((browser) => tests.map((t) => ({ file: t.file, testName: t.name, browser })))
43
- .map((item, index) => ({ ...item, index }));
44
- }
45
- /** Runs the tests of a node on the pool and reports them as a run would */
46
- async function runOnPool(pool, items, onEvent, signal) {
47
- const results = await pool.run(items, {
48
- signal,
49
- onEvent: (event) => onEvent({
50
- type: event.type,
51
- name: event.item.testName,
52
- file: event.item.file,
53
- browser: event.item.browser,
54
- ...(event.type === "test-end" && { result: event.result }),
55
- }),
56
- });
57
- if (signal.aborted)
58
- return "cancelled";
59
- return results.every((r) => r.status === "success") ? "passed" : "failed";
60
- }
61
35
  /** The tests, and the browsers, of each Test and Group node of a flow; a Test node uses the project's browsers */
62
36
  export function nodeTestsOf(flow, tests, groups) {
63
37
  const byId = new Map(tests.map((t) => [t.id, t]));
@@ -152,13 +126,7 @@ export async function checkFlowInProject(options) {
152
126
  const { flow, problems } = await prepare(options);
153
127
  return { flow, problems };
154
128
  }
155
- /**
156
- * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
157
- * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
158
- * its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
159
- * One at a time per process.
160
- */
161
- export async function runFlowInProject(options) {
129
+ async function walkFlowInProject(options) {
162
130
  const { projectDir, flow, unsupported, config, steps, name, environment, tests, groups, problems, } = await prepare(options);
163
131
  const errors = problems.filter((p) => p.level === "error");
164
132
  if (errors.length)
@@ -185,6 +153,12 @@ export async function runFlowInProject(options) {
185
153
  const files = [
186
154
  ...new Set(Object.values(nodeTests).flatMap((list) => list.map((t) => t.file))),
187
155
  ];
156
+ // A failing test is run again as often as the project says, in a node as in a plain run
157
+ const retries = resolveRetries({
158
+ flag: options.retries,
159
+ env: options.retriesEnv,
160
+ config: config.retries,
161
+ });
188
162
  // One pool for the whole run: the nodes, and the branches of a Parallel node, share its workers
189
163
  const pool = workers > 1
190
164
  ? (options.createPool ?? ((o) => new WorkerPool(o)))({
@@ -203,6 +177,7 @@ export async function runFlowInProject(options) {
203
177
  }),
204
178
  files,
205
179
  basePort: options.port ?? DEFAULT_PORT,
180
+ ...(retries > 0 && { retries }),
206
181
  },
207
182
  })
208
183
  : undefined;
@@ -233,6 +208,7 @@ export async function runFlowInProject(options) {
233
208
  expectTimeout: options.expectTimeout,
234
209
  }),
235
210
  ...(options.port !== undefined && { port: options.port }),
211
+ ...(retries > 0 && { retries }),
236
212
  ...(browsers && { browsers }),
237
213
  files: [...new Set(tests.map((t) => t.file))],
238
214
  testNames: tests.map((t) => t.name),
@@ -250,3 +226,19 @@ export async function runFlowInProject(options) {
250
226
  await pool?.dispose();
251
227
  }
252
228
  }
229
+ /**
230
+ * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
231
+ * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
232
+ * its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
233
+ * One at a time per process.
234
+ */
235
+ export async function runFlowInProject(options) {
236
+ // One log for the whole run, across the project sessions it opens one after the other
237
+ const releaseLogs = keepLogFiles(path.resolve(options.projectDir ?? process.cwd()));
238
+ try {
239
+ return await walkFlowInProject(options);
240
+ }
241
+ finally {
242
+ releaseLogs();
243
+ }
244
+ }
@@ -5,4 +5,10 @@ export declare function consoleLine(entry: {
5
5
  meta?: Record<string, unknown> | undefined;
6
6
  }): string;
7
7
  declare const logger: import("winston").Logger;
8
+ /**
9
+ * Writes the log to `error.log` and `combined.log` in `<projectDir>/logs/` until the returned function is called.
10
+ * Nothing is written to disk before a project is in use, so a command that has none (`samurai --version`) leaves
11
+ * no folder behind. Holders of the same project share the files; the files close with the last one.
12
+ */
13
+ export declare function keepLogFiles(projectDir: string): () => void;
8
14
  export default logger;
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { createLogger, format, transports } from "winston";
2
3
  import { maskFormat } from "../config/mask.js";
3
4
  /** Metadata every line carries (`defaultMeta`): not worth printing on each console line */
@@ -15,7 +16,7 @@ const logger = createLogger({
15
16
  }), format.errors({ stack: true }), format.splat(), format.metadata({ key: "meta" }), maskFormat(), format.json({ space: 2 })),
16
17
  defaultMeta: { service: "samurai-framework" },
17
18
  transports: [
18
- // The console shows the log; `logs/error.log` keeps errors and `logs/combined.log` everything
19
+ // The console shows the log; the files of a project's `logs/` folder are added while a project is in use (`keepLogFiles`)
19
20
  new transports.Console({
20
21
  // The `samurai` command quiets this to "warn"; the log files keep everything
21
22
  level: process.env.SAMURAI_LOG_LEVEL ?? "debug",
@@ -27,11 +28,51 @@ const logger = createLogger({
27
28
  meta: meta,
28
29
  }))),
29
30
  }),
30
- new transports.File({
31
- filename: `logs/error.log`,
32
- level: "error",
33
- }),
34
- new transports.File({ filename: `logs/combined.log` }),
35
31
  ],
36
32
  });
33
+ /** The files the log is written to now: `error.log` keeps errors, `combined.log` everything */
34
+ let files;
35
+ /**
36
+ * Writes the log to `error.log` and `combined.log` in `<projectDir>/logs/` until the returned function is called.
37
+ * Nothing is written to disk before a project is in use, so a command that has none (`samurai --version`) leaves
38
+ * no folder behind. Holders of the same project share the files; the files close with the last one.
39
+ */
40
+ export function keepLogFiles(projectDir) {
41
+ const dir = path.join(projectDir, "logs");
42
+ if (files && files.dir !== dir)
43
+ closeFiles();
44
+ files ??= {
45
+ dir,
46
+ transports: [
47
+ new transports.File({
48
+ filename: path.join(dir, "error.log"),
49
+ level: "error",
50
+ }),
51
+ new transports.File({ filename: path.join(dir, "combined.log") }),
52
+ ],
53
+ holders: 0,
54
+ };
55
+ const mine = files;
56
+ if (mine.holders === 0)
57
+ for (const t of mine.transports)
58
+ logger.add(t);
59
+ mine.holders++;
60
+ let released = false;
61
+ return () => {
62
+ if (released)
63
+ return;
64
+ released = true;
65
+ if (--mine.holders === 0 && files === mine)
66
+ closeFiles();
67
+ };
68
+ }
69
+ function closeFiles() {
70
+ if (!files)
71
+ return;
72
+ for (const t of files.transports) {
73
+ logger.remove(t);
74
+ t.close?.();
75
+ }
76
+ files = undefined;
77
+ }
37
78
  export default logger;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Declarations that turn an element into locator candidates (test id, role and name, label, text, css and
3
- * an absolute xpath), plus `inToolbar(event)`, which is true for events from the recorder toolbar. A string
3
+ * an absolute xpath), `uniqueOf(el, candidates)`, the candidates that match only that element right now, and
4
+ * `inToolbar(event)`, which is true for events from the recorder toolbar. A string
4
5
  * of plain declarations, interpolated into the capture script (page realm) and the toolbar script
5
6
  * (sandbox realm), so no transpiler helper can end up in it.
6
7
  */
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Declarations that turn an element into locator candidates (test id, role and name, label, text, css and
3
- * an absolute xpath), plus `inToolbar(event)`, which is true for events from the recorder toolbar. A string
3
+ * an absolute xpath), `uniqueOf(el, candidates)`, the candidates that match only that element right now, and
4
+ * `inToolbar(event)`, which is true for events from the recorder toolbar. A string
4
5
  * of plain declarations, interpolated into the capture script (page realm) and the toolbar script
5
6
  * (sandbox realm), so no transpiler helper can end up in it.
6
7
  */
@@ -100,6 +101,48 @@ const candidatesOf = (el) => {
100
101
  return found;
101
102
  };
102
103
 
104
+ // Not searched by text, as the framework's text locator skips them
105
+ const UNTEXTED = ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "TITLE", "META", "LINK"];
106
+
107
+ // The elements a candidate matches in this document, found the way the framework's locators find them
108
+ const matchesOf = (call) => {
109
+ const all = () => Array.prototype.slice.call(document.querySelectorAll("*"));
110
+ switch (call.method) {
111
+ case "getByTestId":
112
+ return all().filter((node) => node.getAttribute("data-testid") === call.testId);
113
+ case "getByRole":
114
+ return all().filter((node) => roleOf(node) === call.role && nameOf(node, call.role) === call.name);
115
+ case "getByLabel":
116
+ return all().filter((node) => labelTexts(node).indexOf(call.text) >= 0);
117
+ case "getByText": {
118
+ const matches = all().filter((node) => UNTEXTED.indexOf(node.tagName) < 0 && textOf(node) === call.text);
119
+ // Only the innermost count: a wrapper around the matching element is not a match
120
+ return matches.filter((node) => !matches.some((other) => other !== node && node.contains(other)));
121
+ }
122
+ case "getByCss":
123
+ try {
124
+ return Array.prototype.slice.call(document.querySelectorAll(call.css));
125
+ } catch (error) {
126
+ return [];
127
+ }
128
+ case "locator": {
129
+ const found = document.evaluate(call.xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
130
+ const nodes = [];
131
+ for (let i = 0; i < found.snapshotLength; i++) nodes.push(found.snapshotItem(i));
132
+ return nodes;
133
+ }
134
+ default:
135
+ return [];
136
+ }
137
+ };
138
+
139
+ // The candidates that match only el right now: what the recorder falls back to once the page has removed el
140
+ const uniqueOf = (el, candidates) =>
141
+ candidates.filter((call) => {
142
+ const found = matchesOf(call);
143
+ return found.length === 1 && found[0] === el;
144
+ });
145
+
103
146
  const isTextEntry = (el) => {
104
147
  const tag = el.tagName.toLowerCase();
105
148
  if (tag === "textarea") return true;
@@ -5,17 +5,19 @@
5
5
  * listeners; those of the earlier run go quiet, as their run is no longer the current one.
6
6
  * Reports what the tester does in the top-level document:
7
7
  *
8
- * - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
8
+ * - `{ type: "click", target, candidates, unique, textEntry }`: a click, retargeted to the nearest interactive
9
9
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
10
10
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
11
11
  * values never leave the page: `secret` is set and `value` is left out.
12
- * - `{ type: "press", target, candidates, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
12
+ * - `{ type: "press", target, candidates, unique, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
13
13
  * click or typing, which are reported as such).
14
14
  * - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
15
15
  * While Alt is held, the element under the pointer is outlined.
16
16
  *
17
17
  * `candidates` are locator calls for the element, most stable first (test id, role and name, label,
18
18
  * text, css, and an absolute xpath that always exists). The recorder checks each one against the page.
19
+ * `unique` lists the candidates that matched only the element when it was clicked or pressed, before the
20
+ * page's own handlers ran: a button the click removes (Sign in, Delete) is gone by the time the recorder checks.
19
21
  * Runs in the page's own realm; a string, so no transpiler helper can end up in it.
20
22
  */
21
23
  export declare const CAPTURE_SOURCE: string;
@@ -12,17 +12,19 @@ const RUN_KEY = "samurai.recorder.run";
12
12
  * listeners; those of the earlier run go quiet, as their run is no longer the current one.
13
13
  * Reports what the tester does in the top-level document:
14
14
  *
15
- * - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
15
+ * - `{ type: "click", target, candidates, unique, textEntry }`: a click, retargeted to the nearest interactive
16
16
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
17
17
  * - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
18
18
  * values never leave the page: `secret` is set and `value` is left out.
19
- * - `{ type: "press", target, candidates, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
19
+ * - `{ type: "press", target, candidates, unique, pressed }`: Enter, Tab or Escape (Enter in a button, link or textarea is a
20
20
  * click or typing, which are reported as such).
21
21
  * - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
22
22
  * While Alt is held, the element under the pointer is outlined.
23
23
  *
24
24
  * `candidates` are locator calls for the element, most stable first (test id, role and name, label,
25
25
  * text, css, and an absolute xpath that always exists). The recorder checks each one against the page.
26
+ * `unique` lists the candidates that matched only the element when it was clicked or pressed, before the
27
+ * page's own handlers ran: a button the click removes (Sign in, Delete) is gone by the time the recorder checks.
26
28
  * Runs in the page's own realm; a string, so no transpiler helper can end up in it.
27
29
  */
28
30
  export const CAPTURE_SOURCE = String.raw `(send) => {
@@ -39,6 +41,7 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
39
41
  const MUTED = Symbol.for("${MUTED_KEY}");
40
42
  const off = () => window[RUN] !== run || window[OFF] === true || window[MUTED] === true;
41
43
  let lastClick = { el: null, at: 0 };
44
+ let lastEnter = { form: null, at: 0 };
42
45
  let outlined = null;
43
46
  let outlinedStyle = "";
44
47
  const unhighlight = () => {
@@ -69,8 +72,11 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
69
72
  const now = Date.now();
70
73
  // The browser forwards a label click to its control as a second click
71
74
  if (lastClick.el === el && now - lastClick.at < 150) return;
75
+ // An Enter in a field submits its form with a click on the submit button: the Enter is the step
76
+ if (event.detail === 0 && el.form && el.form === lastEnter.form && now - lastEnter.at < 150) return;
72
77
  lastClick = { el, at: now };
73
- report({ type: "click", target: el, candidates: candidatesOf(el), textEntry: isTextEntry(el) });
78
+ const candidates = candidatesOf(el);
79
+ report({ type: "click", target: el, candidates, unique: uniqueOf(el, candidates), textEntry: isTextEntry(el) });
74
80
  }, true);
75
81
 
76
82
  document.addEventListener("input", (event) => {
@@ -98,7 +104,9 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
98
104
  if (RECORDED_KEYS.indexOf(event.key) < 0 || !(event.target instanceof Element)) return;
99
105
  const el = event.target;
100
106
  if (event.key === "Enter" && (el.closest(ACTIVATES) || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
101
- report({ type: "press", target: el, candidates: candidatesOf(el), pressed: event.key });
107
+ const candidates = candidatesOf(el);
108
+ report({ type: "press", target: el, candidates, unique: uniqueOf(el, candidates), pressed: event.key });
109
+ if (event.key === "Enter") lastEnter = { form: el.form || null, at: Date.now() };
102
110
  }, true);
103
111
 
104
112
  document.addEventListener("mousemove", (event) => {
@@ -23,6 +23,8 @@ export interface PageMessage {
23
23
  type: "click" | "input" | "press" | "assert";
24
24
  target: ElementHandle;
25
25
  candidates: LocatorCall[];
26
+ /** Of a click or press: the candidates that matched only the target when it happened, checked in the page */
27
+ unique?: LocatorCall[];
26
28
  textEntry?: boolean;
27
29
  /** The key of a `press` message */
28
30
  pressed?: string;
@@ -7,3 +7,5 @@ import type { LocatorCall, LocatorSpec } from "../steps/model.js";
7
7
  * become its fallbacks. Returns undefined when no candidate qualifies.
8
8
  */
9
9
  export declare function rankLocator(page: Page, target: ElementHandle, candidates: readonly LocatorCall[], maxFallbacks?: number): Promise<LocatorSpec | undefined>;
10
+ /** The first of `verified` as the primary, up to `maxFallbacks` more as its fallbacks; undefined when empty */
11
+ export declare function specOf(verified: readonly LocatorCall[], maxFallbacks?: number): LocatorSpec | undefined;
@@ -17,6 +17,13 @@ export async function rankLocator(page, target, candidates, maxFallbacks = 2) {
17
17
  if (found.length === 1 && found[0].sharedId === target.sharedId)
18
18
  verified.push(candidate);
19
19
  }
20
+ return specOf(verified, maxFallbacks);
21
+ }
22
+ /** The first of `verified` as the primary, up to `maxFallbacks` more as its fallbacks; undefined when empty */
23
+ export function specOf(verified, maxFallbacks = 2) {
20
24
  const [primary, ...fallbacks] = verified;
21
- return (primary && { chain: [primary], fallbacks: fallbacks.map((call) => [call]) });
25
+ return (primary && {
26
+ chain: [primary],
27
+ fallbacks: fallbacks.slice(0, maxFallbacks).map((call) => [call]),
28
+ });
22
29
  }
@@ -9,7 +9,7 @@ import { FilesRecorder, missingFileText } from "./files-recorder.js";
9
9
  import { Normaliser } from "./normalise.js";
10
10
  import { observationFor, } from "./page-messages.js";
11
11
  import { listSandboxRealms } from "./realms.js";
12
- import { rankLocator } from "./ranking.js";
12
+ import { rankLocator, specOf } from "./ranking.js";
13
13
  import { TOOLBAR_HOOK, ToolbarBridge } from "./toolbar-bridge.js";
14
14
  import { TOOLBAR_SANDBOX } from "./toolbar/protocol.js";
15
15
  import { TOOLBAR_SOURCE } from "./toolbar/source.js";
@@ -216,7 +216,10 @@ export class Recorder {
216
216
  const { sharedId } = message.target;
217
217
  if (this.known.has(sharedId))
218
218
  return this.known.get(sharedId);
219
- const spec = await rankLocator(this.page, message.target, message.candidates);
219
+ // The page may have removed the element by now (a Sign in that leaves the form): then what matched
220
+ // only it when it was used is the best there is
221
+ const spec = (await rankLocator(this.page, message.target, message.candidates)) ??
222
+ specOf(message.unique ?? []);
220
223
  this.known.set(sharedId, spec);
221
224
  return spec;
222
225
  }
@@ -0,0 +1,26 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
2
+ import type { AllHookFixtures, RegisteredTestCase } from "../types/test.js";
3
+ import { type HookError } from "./hooks.js";
4
+ export interface AllHooksOptions {
5
+ /** The limit of each beforeAll / afterAll hook, in ms */
6
+ timeout: number;
7
+ /** The fixtures the hooks of a browser get */
8
+ fixtures(browser: SupportedBrowser): AllHookFixtures;
9
+ }
10
+ /**
11
+ * The `beforeAll` and `afterAll` hooks of one worker: each runs once per suite and browser, `beforeAll` when the
12
+ * first test of the suite arrives and `afterAll` when the worker is done
13
+ */
14
+ export declare class AllHooks {
15
+ private readonly options;
16
+ private readonly scopes;
17
+ /** In the order the suites were entered */
18
+ private readonly entered;
19
+ constructor(options: AllHooksOptions);
20
+ /** Runs the `beforeAll` hooks of the suites around `test` that this worker has not run for `browser`. Rejects with the `HookError` of one that failed, now or earlier */
21
+ ensure(test: RegisteredTestCase, browser: SupportedBrowser): Promise<void>;
22
+ /** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
23
+ closeAll(): Promise<HookError[]>;
24
+ private enter;
25
+ private runBefore;
26
+ }
@@ -0,0 +1,61 @@
1
+ import { runHook } from "./hooks.js";
2
+ import { suiteChain } from "./suite.js";
3
+ /**
4
+ * The `beforeAll` and `afterAll` hooks of one worker: each runs once per suite and browser, `beforeAll` when the
5
+ * first test of the suite arrives and `afterAll` when the worker is done
6
+ */
7
+ export class AllHooks {
8
+ constructor(options) {
9
+ this.options = options;
10
+ this.scopes = new Map();
11
+ /** In the order the suites were entered */
12
+ this.entered = [];
13
+ }
14
+ /** Runs the `beforeAll` hooks of the suites around `test` that this worker has not run for `browser`. Rejects with the `HookError` of one that failed, now or earlier */
15
+ async ensure(test, browser) {
16
+ for (const suite of suiteChain(test.suite)) {
17
+ if (!suite.beforeAll?.length && !suite.afterAll?.length)
18
+ continue;
19
+ await this.enter(suite, browser).ready;
20
+ }
21
+ }
22
+ /** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
23
+ async closeAll() {
24
+ const errors = [];
25
+ const entered = this.entered.splice(0).reverse();
26
+ this.scopes.clear();
27
+ for (const { suite, browser, ready } of entered) {
28
+ await ready.catch(() => undefined);
29
+ for (const hook of suite.afterAll ?? []) {
30
+ try {
31
+ await runHook("afterAll", hook, this.options.fixtures(browser), this.options.timeout);
32
+ }
33
+ catch (error) {
34
+ errors.push(error);
35
+ }
36
+ }
37
+ }
38
+ return errors;
39
+ }
40
+ enter(suite, browser) {
41
+ const ofSuite = this.scopes.get(suite) ?? new Map();
42
+ this.scopes.set(suite, ofSuite);
43
+ const known = ofSuite.get(browser);
44
+ if (known)
45
+ return known;
46
+ const scope = {
47
+ suite,
48
+ browser,
49
+ ready: this.runBefore(suite, browser),
50
+ };
51
+ // Rejections are handled by the callers of `ensure`; one nobody waits for is not an error of the process
52
+ scope.ready.catch(() => undefined);
53
+ ofSuite.set(browser, scope);
54
+ this.entered.push(scope);
55
+ return scope;
56
+ }
57
+ async runBefore(suite, browser) {
58
+ for (const hook of suite.beforeAll ?? [])
59
+ await runHook("beforeAll", hook, this.options.fixtures(browser), this.options.timeout);
60
+ }
61
+ }
@@ -0,0 +1,5 @@
1
+ import type { RegisteredTestCase } from "../types/test.js";
2
+ /** `test.only` is in the specs and the config refuses it: one error that lists every focused test */
3
+ export declare function assertNoOnly(all: readonly RegisteredTestCase[], forbid: boolean, srcDir: string): void;
4
+ /** When any of the selected tests is focused, only the focused ones; the others are not run and not reported */
5
+ export declare function narrowToOnly(selected: RegisteredTestCase[]): RegisteredTestCase[];
@@ -0,0 +1,19 @@
1
+ import path from "path";
2
+ import { toPosix } from "./groups.js";
3
+ /** `test.only` is in the specs and the config refuses it: one error that lists every focused test */
4
+ export function assertNoOnly(all, forbid, srcDir) {
5
+ if (!forbid)
6
+ return;
7
+ const focused = all.filter((test) => test.modifier === "only");
8
+ if (focused.length === 0)
9
+ return;
10
+ throw new Error(focused
11
+ .map((test) => `test.only is not allowed (forbidOnly): ${toPosix(path.relative(srcDir, test.file))}: ${test.name}`)
12
+ .join("\n"));
13
+ }
14
+ /** When any of the selected tests is focused, only the focused ones; the others are not run and not reported */
15
+ export function narrowToOnly(selected) {
16
+ return selected.some((test) => test.modifier === "only")
17
+ ? selected.filter((test) => test.modifier === "only")
18
+ : selected;
19
+ }
@@ -0,0 +1,24 @@
1
+ import type { Suite, TestFixtures } from "../types/test.js";
2
+ import { type HookKind } from "./suite.js";
3
+ /** A hook threw or ran out of time: the message names the hook (`beforeEach hook failed: <message>`) */
4
+ export declare class HookError extends Error {
5
+ readonly kind: HookKind;
6
+ readonly cause: unknown;
7
+ constructor(kind: HookKind, cause: unknown);
8
+ }
9
+ /**
10
+ * Runs one hook with its fixtures and waits for it. Anything it throws, and a hook that is still running after
11
+ * `limitMs` (when given), becomes a `HookError` of that kind.
12
+ */
13
+ export declare function runHook<F>(kind: HookKind, hook: (fixtures: F) => Promise<void> | void, fixtures: F, limitMs?: number): Promise<void>;
14
+ /**
15
+ * Runs a test body between the `beforeEach` and `afterEach` hooks of its suites.
16
+ *
17
+ * - A failing `beforeEach` stops the other `beforeEach` hooks and the body; the `afterEach` hooks still run.
18
+ * - The `afterEach` hooks run when the body ended by itself (it passed, threw or skipped), not when `signal` says
19
+ * the test was cut short (timeout, abort, closed browser): the time or the page is gone. They all run, even
20
+ * after one fails.
21
+ * - What it throws: the `beforeEach` failure, else the body's error, else the first `afterEach` failure. A skip is
22
+ * overruled by a failing `afterEach`.
23
+ */
24
+ export declare function runWithEachHooks(suite: Suite, fixtures: TestFixtures, signal: AbortSignal, body: () => Promise<void>): Promise<void>;
@@ -0,0 +1,79 @@
1
+ import { isSkipSignal } from "./skip.js";
2
+ import { afterEachOf, beforeEachOf } from "./suite.js";
3
+ /** A hook threw or ran out of time: the message names the hook (`beforeEach hook failed: <message>`) */
4
+ export class HookError extends Error {
5
+ constructor(kind, cause) {
6
+ super(`${kind} hook failed: ${cause instanceof Error ? cause.message : String(cause)}`);
7
+ this.kind = kind;
8
+ this.cause = cause;
9
+ this.name = "HookError";
10
+ }
11
+ }
12
+ /**
13
+ * Runs one hook with its fixtures and waits for it. Anything it throws, and a hook that is still running after
14
+ * `limitMs` (when given), becomes a `HookError` of that kind.
15
+ */
16
+ export async function runHook(kind, hook, fixtures, limitMs) {
17
+ let timer;
18
+ try {
19
+ const running = Promise.resolve().then(() => hook(fixtures));
20
+ if (limitMs === undefined)
21
+ return await running;
22
+ await Promise.race([
23
+ running,
24
+ new Promise((_, reject) => {
25
+ timer = setTimeout(() => reject(new Error(`timed out after ${limitMs / 1000} seconds`)), limitMs);
26
+ }),
27
+ ]);
28
+ }
29
+ catch (error) {
30
+ // `test.skip(condition)` in a hook skips the test: it is not a failure of the hook
31
+ if (isSkipSignal(error))
32
+ throw error;
33
+ throw new HookError(kind, error);
34
+ }
35
+ finally {
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+ /**
40
+ * Runs a test body between the `beforeEach` and `afterEach` hooks of its suites.
41
+ *
42
+ * - A failing `beforeEach` stops the other `beforeEach` hooks and the body; the `afterEach` hooks still run.
43
+ * - The `afterEach` hooks run when the body ended by itself (it passed, threw or skipped), not when `signal` says
44
+ * the test was cut short (timeout, abort, closed browser): the time or the page is gone. They all run, even
45
+ * after one fails.
46
+ * - What it throws: the `beforeEach` failure, else the body's error, else the first `afterEach` failure. A skip is
47
+ * overruled by a failing `afterEach`.
48
+ */
49
+ export async function runWithEachHooks(suite, fixtures, signal, body) {
50
+ let primary;
51
+ let failed = false;
52
+ try {
53
+ for (const hook of beforeEachOf(suite))
54
+ await runHook("beforeEach", hook, fixtures);
55
+ await body();
56
+ }
57
+ catch (error) {
58
+ primary = error;
59
+ failed = true;
60
+ }
61
+ let afterError;
62
+ let afterFailed = false;
63
+ if (!signal.aborted) {
64
+ for (const hook of afterEachOf(suite)) {
65
+ try {
66
+ await runHook("afterEach", hook, fixtures);
67
+ }
68
+ catch (error) {
69
+ if (!afterFailed)
70
+ afterError = error;
71
+ afterFailed = true;
72
+ }
73
+ }
74
+ }
75
+ if (afterFailed && (!failed || isSkipSignal(primary)))
76
+ throw afterError;
77
+ if (failed)
78
+ throw primary;
79
+ }
@@ -18,6 +18,12 @@ export interface RunnerOptions {
18
18
  * @default the config's `workers`, else half the CPUs
19
19
  */
20
20
  workers?: number | string;
21
+ /**
22
+ * How many times a failing test is run again, each time with a fresh browser (the command line's `--retries`).
23
+ * Beats `SAMURAI_RETRIES` and the config's `retries`.
24
+ * @default the config's `retries`, else 0
25
+ */
26
+ retries?: number | string;
21
27
  /** Run the browser without a window. @default false */
22
28
  headless?: boolean;
23
29
  /** Remote debugging port of the browser. @default DEFAULT_PORT (9223) */