@itmetsam/samurai-framework 0.15.0 → 0.15.2

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 (54) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/cli/args.d.ts +4 -1
  3. package/dist/cli/args.js +7 -2
  4. package/dist/cli/console-reporter.js +11 -2
  5. package/dist/cli/flow.js +5 -0
  6. package/dist/cli/main.js +2 -1
  7. package/dist/cli/usage.d.ts +1 -1
  8. package/dist/cli/usage.js +2 -0
  9. package/dist/config/config.js +3 -0
  10. package/dist/config/retries.d.ts +8 -0
  11. package/dist/config/retries.js +19 -0
  12. package/dist/flows/run/node-pool.d.ts +9 -0
  13. package/dist/flows/run/node-pool.js +27 -0
  14. package/dist/flows/run/project-options.d.ts +44 -0
  15. package/dist/flows/run/project-options.js +8 -0
  16. package/dist/flows/run/project.d.ts +4 -40
  17. package/dist/flows/run/project.js +12 -33
  18. package/dist/recorder/candidates.d.ts +2 -1
  19. package/dist/recorder/candidates.js +44 -1
  20. package/dist/recorder/capture.d.ts +4 -2
  21. package/dist/recorder/capture.js +12 -4
  22. package/dist/recorder/page-messages.d.ts +2 -0
  23. package/dist/recorder/ranking.d.ts +2 -0
  24. package/dist/recorder/ranking.js +8 -1
  25. package/dist/recorder/recorder.js +5 -2
  26. package/dist/runner/options.d.ts +6 -0
  27. package/dist/runner/pooled-run.js +8 -7
  28. package/dist/runner/registry.d.ts +13 -4
  29. package/dist/runner/registry.js +26 -9
  30. package/dist/runner/reporter.d.ts +6 -4
  31. package/dist/runner/reporter.js +7 -1
  32. package/dist/runner/retries-option.d.ts +10 -0
  33. package/dist/runner/retries-option.js +12 -0
  34. package/dist/runner/retry.d.ts +13 -0
  35. package/dist/runner/retry.js +43 -0
  36. package/dist/runner/run-test.d.ts +2 -0
  37. package/dist/runner/run-test.js +8 -2
  38. package/dist/runner/run.d.ts +3 -0
  39. package/dist/runner/run.js +3 -1
  40. package/dist/runner/skip.d.ts +2 -1
  41. package/dist/runner/skip.js +3 -2
  42. package/dist/runner/suite.d.ts +10 -4
  43. package/dist/runner/suite.js +19 -10
  44. package/dist/runner/test-runner.js +15 -6
  45. package/dist/runner/worker-pool.d.ts +1 -0
  46. package/dist/runner/worker-pool.js +1 -0
  47. package/dist/runner/worker-protocol.d.ts +3 -0
  48. package/dist/runner/worker-session.js +6 -3
  49. package/dist/runner/worker.js +3 -0
  50. package/dist/steps/model.d.ts +5 -0
  51. package/dist/steps/parse.js +59 -24
  52. package/dist/types/config.d.ts +7 -0
  53. package/dist/types/test.d.ts +22 -2
  54. package/package.json +2 -1
@@ -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
  }
@@ -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) */
@@ -70,18 +70,19 @@ export function poolMode(input) {
70
70
  export async function runPooled(pool, planned, items, reporter, signal) {
71
71
  reporter.setOrder(planned);
72
72
  for (const { test, browser } of planned) {
73
- const annotation = staticSkip(test);
74
- if (annotation)
75
- reporter.onTestSkipped(test, browser, { annotation });
73
+ const skip = staticSkip(test);
74
+ if (skip)
75
+ reporter.onTestSkipped(test, browser, skip);
76
76
  }
77
77
  const started = new Set();
78
78
  const ended = new Set();
79
- const begin = (index) => {
80
- if (started.has(index))
79
+ const begin = (index, retry) => {
80
+ // A test run again starts again; the first start is reported once however it arrives
81
+ if (started.has(index) && !retry)
81
82
  return;
82
83
  started.add(index);
83
84
  const { test, browser } = planned[index];
84
- reporter.onTestStart(test, browser);
85
+ reporter.onTestStart(test, browser, retry);
85
86
  };
86
87
  const end = (index, result) => {
87
88
  begin(index);
@@ -92,7 +93,7 @@ export async function runPooled(pool, planned, items, reporter, signal) {
92
93
  const results = await pool.run(items, {
93
94
  ...(signal && { signal }),
94
95
  onEvent: (event) => event.type === "test-start"
95
- ? begin(event.item.index)
96
+ ? begin(event.item.index, event.retry)
96
97
  : end(event.item.index, event.result),
97
98
  });
98
99
  results.forEach((result, position) => {
@@ -1,4 +1,4 @@
1
- import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase } from "../types/test.js";
1
+ import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase, TestDetails } from "../types/test.js";
2
2
  type Body = TestCaseBase["function"];
3
3
  /** `test(title, fn)`, `test.skip(title, fn)`, `test.skip(condition, reason)` and the other forms */
4
4
  export interface TestFunction {
@@ -10,16 +10,25 @@ export interface TestFunction {
10
10
  /** Runs only this test (and the other `only` ones) */
11
11
  only(title: string, fn: Body): void;
12
12
  }
13
- /** With a title and a body it registers a skipped test; with a condition it skips the running test when the condition is true */
13
+ /**
14
+ * With a title and a body it registers a skipped test, `{ reason }` saying why; with a condition it skips the
15
+ * running test when the condition is true
16
+ */
14
17
  interface SkipFunction {
15
18
  (title: string, fn: Body): void;
19
+ (title: string, details: TestDetails, fn: Body): void;
16
20
  (condition: boolean, reason?: string): void;
17
21
  }
22
+ /** `describe.skip` and `describe.fixme`: every test inside gets the modifier, and `{ reason }` says why */
23
+ interface DescribeSkipFunction {
24
+ (title: string, fn: () => void): void;
25
+ (title: string, details: TestDetails, fn: () => void): void;
26
+ }
18
27
  /** `describe(title, fn)` and the same with a modifier on every test inside */
19
28
  export interface DescribeFunction {
20
29
  (title: string, fn: () => void): void;
21
- skip(title: string, fn: () => void): void;
22
- fixme(title: string, fn: () => void): void;
30
+ skip: DescribeSkipFunction;
31
+ fixme: DescribeSkipFunction;
23
32
  only(title: string, fn: () => void): void;
24
33
  }
25
34
  export declare const test: TestFunction;
@@ -4,8 +4,8 @@ import { SkipSignal } from "./skip.js";
4
4
  import { addHook, currentSuite, enterSuite, leaveSuite, modifierOf, resetSuites, } from "./suite.js";
5
5
  const registered = [];
6
6
  /** Runs `fn` as the `describe` block `title`, under `modifier` when there is one. `fn` runs immediately and must be synchronous. */
7
- function openDescribe(file, title, fn, modifier) {
8
- enterSuite(file, title, modifier);
7
+ function openDescribe(file, title, fn, modifier, reason) {
8
+ enterSuite(file, title, modifier, reason);
9
9
  try {
10
10
  const result = fn();
11
11
  if (result instanceof Promise) {
@@ -17,18 +17,17 @@ function openDescribe(file, title, fn, modifier) {
17
17
  leaveSuite();
18
18
  }
19
19
  }
20
- function add(file, title, fn, modifier) {
20
+ function add(file, title, fn, modifier, reason) {
21
21
  const suite = currentSuite(file);
22
22
  const titlePath = [...titlesOf(suite), title];
23
23
  const name = titlePath.join(" > ");
24
- const effective = modifierOf(modifier, suite);
25
24
  registered.push({
26
25
  name,
27
26
  titlePath,
28
27
  function: fn,
29
28
  file,
30
29
  suite,
31
- ...(effective && { modifier: effective }),
30
+ ...modifierOf({ modifier, ...(reason !== undefined && { reason }) }, suite),
32
31
  });
33
32
  logger.debug("Registered test %s", name);
34
33
  }
@@ -40,26 +39,44 @@ function titlesOf(suite) {
40
39
  titles.unshift(s.title);
41
40
  return titles;
42
41
  }
42
+ /** The body and `{ reason }` of `(title, fn)` and `(title, details, fn)` */
43
+ function bodyAndReason(call, title, second, third) {
44
+ const [details, fn] = typeof second === "function"
45
+ ? [undefined, second]
46
+ : [second, third];
47
+ if (typeof fn !== "function")
48
+ throw new TypeError(`${call}(${JSON.stringify(title)}) needs a function as its last argument`);
49
+ const reason = details?.reason;
50
+ return { fn: fn, ...(reason !== undefined && { reason }) };
51
+ }
43
52
  /** `test.skip` and `test.fixme`: a title and a body register a test with the modifier; a condition skips the running test */
44
53
  function skipping(annotation) {
45
- return ((first, second) => {
54
+ return ((first, second, third) => {
46
55
  if (typeof first === "string") {
56
+ const { fn, reason } = bodyAndReason(`test.${annotation}`, first, second, third);
47
57
  // Frames: Error, callerFile, this function, the spec
48
- add(callerFile(), first, second, annotation);
58
+ add(callerFile(), first, fn, annotation, reason);
49
59
  return;
50
60
  }
51
61
  if (first)
52
62
  throw new SkipSignal(annotation, second);
53
63
  });
54
64
  }
65
+ /** `describe.skip` and `describe.fixme`, with or without `{ reason }` */
66
+ function describeSkipping(annotation) {
67
+ return ((title, second, third) => {
68
+ const { fn, reason } = bodyAndReason(`describe.${annotation}`, title, second, third);
69
+ openDescribe(callerFile(), title, fn, annotation, reason);
70
+ });
71
+ }
55
72
  export const test = Object.assign((title, fn) => add(callerFile(), title, fn), {
56
73
  skip: skipping("skip"),
57
74
  fixme: skipping("fixme"),
58
75
  only: (title, fn) => add(callerFile(), title, fn, "only"),
59
76
  });
60
77
  export const describe = Object.assign((title, fn) => openDescribe(callerFile(), title, fn), {
61
- skip: (title, fn) => openDescribe(callerFile(), title, fn, "skip"),
62
- fixme: (title, fn) => openDescribe(callerFile(), title, fn, "fixme"),
78
+ skip: describeSkipping("skip"),
79
+ fixme: describeSkipping("fixme"),
63
80
  only: (title, fn) => openDescribe(callerFile(), title, fn, "only"),
64
81
  });
65
82
  /** Runs before each test of the `describe` block it is written in (of the file, at the top level) */
@@ -1,4 +1,4 @@
1
- import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestSummary } from "../types/test.js";
1
+ import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestRetries, TestSummary } from "../types/test.js";
2
2
  import type { SupportedBrowser } from "../types/browser.js";
3
3
  /** What a run reports while it happens */
4
4
  export type RunEvent = {
@@ -14,6 +14,8 @@ export type RunEvent = {
14
14
  name: string;
15
15
  file: string;
16
16
  browser: SupportedBrowser;
17
+ /** The attempt, counting from 1, when the test is run again after a failure; absent for the first */
18
+ retry?: number;
17
19
  } | {
18
20
  type: "test-end";
19
21
  name: string;
@@ -36,7 +38,7 @@ export interface ReporterOptions {
36
38
  }
37
39
  /** What `runOneTest` reports a test to: the reporter of a run, or a worker that relays it */
38
40
  export interface TestSink {
39
- onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
41
+ onTestStart(test: RegisteredTestCase, browser: SupportedBrowser, retry?: number): void;
40
42
  onTestEnd(test: RegisteredTestCase, browser: SupportedBrowser, error?: TestError, extras?: TestEndExtras): void;
41
43
  /** A test that does not run: reported as started and ended at once */
42
44
  onTestSkipped(test: RegisteredTestCase, browser: SupportedBrowser, skip: TestSkip): void;
@@ -47,7 +49,7 @@ export interface TestSkip {
47
49
  reason?: string | undefined;
48
50
  }
49
51
  /** What a test's report entry lists besides its status */
50
- export interface TestEndExtras {
52
+ export interface TestEndExtras extends TestRetries {
51
53
  logs?: TestLogEntry[];
52
54
  logsDropped?: number;
53
55
  screenshots?: {
@@ -73,7 +75,7 @@ export default class TestReporter implements TestSink {
73
75
  }[]): void;
74
76
  onStart(total: number, workers?: number): void;
75
77
  onEnd(): Promise<TestSummary>;
76
- onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
78
+ onTestStart(test: RegisteredTestCase, browser: SupportedBrowser, retry?: number): void;
77
79
  onTestSkipped(test: RegisteredTestCase, browser: SupportedBrowser, { annotation, reason }: TestSkip): void;
78
80
  /**
79
81
  * Ends a test that ran in a worker: the worker built the result (masked, with its own duration), this keeps the
@@ -75,7 +75,7 @@ export default class TestReporter {
75
75
  this.emit({ type: "run-end", summary: this.summary });
76
76
  return this.summary;
77
77
  }
78
- onTestStart(test, browser) {
78
+ onTestStart(test, browser, retry) {
79
79
  const key = keyOf(test, browser);
80
80
  const t = performance.mark(`${key}-start`);
81
81
  this.testResults.set(key, {
@@ -90,6 +90,7 @@ export default class TestReporter {
90
90
  name: test.name,
91
91
  file: test.file,
92
92
  browser,
93
+ ...(retry ? { retry } : {}),
93
94
  });
94
95
  }
95
96
  onTestSkipped(test, browser, { annotation, reason }) {
@@ -145,6 +146,11 @@ export default class TestReporter {
145
146
  ...(extras?.logs && { logs: extras.logs }),
146
147
  ...(extras?.logsDropped && { logsDropped: extras.logsDropped }),
147
148
  ...(extras?.screenshots?.length && { screenshots: extras.screenshots }),
149
+ ...(extras?.retry && { retry: extras.retry }),
150
+ ...(extras?.flaky && { flaky: true }),
151
+ ...(extras?.previousAttempts?.length && {
152
+ previousAttempts: extras.previousAttempts,
153
+ }),
148
154
  });
149
155
  this.testResults.set(key, result);
150
156
  this.emit({
@@ -0,0 +1,10 @@
1
+ export interface RetriesInput {
2
+ /** The command line's `--retries` (or the `retries` run option) */
3
+ flag?: number | string | undefined;
4
+ /** `SAMURAI_RETRIES` */
5
+ env?: string | undefined;
6
+ /** The config's `retries` */
7
+ config?: number | string | undefined;
8
+ }
9
+ /** How many times a failing test is run again: the flag, else `SAMURAI_RETRIES`, else the config's `retries`, else 0 */
10
+ export declare function resolveRetries({ flag, env, config }: RetriesInput): number;
@@ -0,0 +1,12 @@
1
+ import { parseRetries } from "../config/retries.js";
2
+ /** How many times a failing test is run again: the flag, else `SAMURAI_RETRIES`, else the config's `retries`, else 0 */
3
+ export function resolveRetries({ flag, env, config }) {
4
+ const fromEnv = env?.trim() ? env : undefined;
5
+ if (flag !== undefined)
6
+ return parseRetries(flag, "--retries");
7
+ if (fromEnv !== undefined)
8
+ return parseRetries(fromEnv, "SAMURAI_RETRIES");
9
+ if (config !== undefined)
10
+ return parseRetries(config, "retries");
11
+ return 0;
12
+ }
@@ -0,0 +1,13 @@
1
+ import type { SupportedBrowser } from "../types/browser.js";
2
+ import type { RegisteredTestCase } from "../types/test.js";
3
+ import type { TestSink } from "./reporter.js";
4
+ /** One attempt at a test: it reports to `sink` and `attempt` counts from 0 */
5
+ export type Attempt = (sink: TestSink, attempt: number) => Promise<void>;
6
+ /**
7
+ * Runs a test, and again after a failure with a fresh browser, up to `retries` more times. The test starts once
8
+ * per attempt (`retry` counts from 1), and ends once, with the last attempt's result: `retry` says which attempt
9
+ * that was, `previousAttempts` holds the errors before it, and `flaky` marks a pass after a failure.
10
+ *
11
+ * An aborted run is never run again. A test that skips itself ends there and then.
12
+ */
13
+ export declare function runWithRetries(test: RegisteredTestCase, browser: SupportedBrowser, retries: number, signal: AbortSignal | undefined, attempt: Attempt, sink: TestSink): Promise<void>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Runs a test, and again after a failure with a fresh browser, up to `retries` more times. The test starts once
3
+ * per attempt (`retry` counts from 1), and ends once, with the last attempt's result: `retry` says which attempt
4
+ * that was, `previousAttempts` holds the errors before it, and `flaky` marks a pass after a failure.
5
+ *
6
+ * An aborted run is never run again. A test that skips itself ends there and then.
7
+ */
8
+ export async function runWithRetries(test, browser, retries, signal, attempt, sink) {
9
+ if (retries <= 0)
10
+ return attempt(sink, 0);
11
+ const previous = [];
12
+ for (let n = 0;; n++) {
13
+ let held;
14
+ const attemptSink = {
15
+ onTestStart: (t, b) => sink.onTestStart(t, b, n > 0 ? n : undefined),
16
+ onTestEnd: (_t, _b, error, extras) => {
17
+ held = { error, extras };
18
+ },
19
+ onTestSkipped: (t, b, skip) => sink.onTestSkipped(t, b, skip),
20
+ };
21
+ await attempt(attemptSink, n);
22
+ // Skipped: already reported, and nothing is run again
23
+ if (!held)
24
+ return;
25
+ const { error, extras } = held;
26
+ if (error &&
27
+ n < retries &&
28
+ !signal?.aborted &&
29
+ error.message !== "Run aborted") {
30
+ previous.push(error);
31
+ continue;
32
+ }
33
+ sink.onTestEnd(test, browser, error, {
34
+ ...extras,
35
+ ...(n > 0 && {
36
+ retry: n,
37
+ previousAttempts: [...previous],
38
+ ...(!error && { flaky: true }),
39
+ }),
40
+ });
41
+ return;
42
+ }
43
+ }
@@ -12,6 +12,8 @@ export interface WorkerResources {
12
12
  profileDir?: string;
13
13
  /** The `workerIndex` fixture. @default 0 */
14
14
  workerIndex?: number;
15
+ /** Which attempt this is, counting from 0: screenshots of a retry go to a `retry-<n>` folder of their own. @default 0 */
16
+ attempt?: number;
15
17
  /** The `beforeAll` / `afterAll` hooks of this worker: kept across tests, closed when the worker is done */
16
18
  allHooks?: AllHooks;
17
19
  }
@@ -10,7 +10,7 @@ import { ConnectionClosedError } from "../transport/bidi-connection.js";
10
10
  import { judgePageLogs } from "./page-logs-report.js";
11
11
  import { DEFAULT_PORT } from "./ports.js";
12
12
  import { runWithEachHooks } from "./hooks.js";
13
- import { isSkipSignal } from "./skip.js";
13
+ import { isSkipSignal, staticSkip } from "./skip.js";
14
14
  import { testDirOf } from "./test-dir.js";
15
15
  import { toTestError } from "./test-error.js";
16
16
  /**
@@ -64,6 +64,12 @@ export function allHookFixtures(run, browserName, workerIndex = 0) {
64
64
  }
65
65
  /** Runs one test in one browser and reports it: launch, run the body, judge the page's logs, close the browser */
66
66
  export async function runOneTest(test, browserName, run, options, srcDir, reporter, resources = {}) {
67
+ // A test marked skip or fixme never runs, whoever asked for it (a flow's test node, a worker)
68
+ const marked = staticSkip(test);
69
+ if (marked) {
70
+ reporter.onTestSkipped(test, browserName, marked);
71
+ return;
72
+ }
67
73
  logger.verbose("Starting test: %s in %s", test.name, browserName);
68
74
  reporter.onTestStart(test, browserName);
69
75
  const outcome = new TestOutcome(reporter, test, browserName);
@@ -125,7 +131,7 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
125
131
  browserGone();
126
132
  });
127
133
  page = launched.page;
128
- outcome.evidence = new Evidence(resultDir, `${browserName}/${testDirOf(test, srcDir)}`);
134
+ outcome.evidence = new Evidence(resultDir, `${browserName}/${testDirOf(test, srcDir)}${resources.attempt ? `/retry-${resources.attempt}` : ""}`);
129
135
  launched.page.setEvidence(outcome.evidence);
130
136
  const fixtures = {
131
137
  page: launched.page,
@@ -21,6 +21,8 @@ export interface FoundTest {
21
21
  file: string;
22
22
  /** `skip` or `fixme` (not run), or `only` (focused); absent for a plain test. A modifier on a `describe` counts. */
23
23
  modifier?: Modifier;
24
+ /** The `{ reason }` given with the `skip` or `fixme` that applies */
25
+ reason?: string;
24
26
  }
25
27
  /** Imports the project's specs without running anything and lists the tests the options select */
26
28
  export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
@@ -28,6 +30,7 @@ export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[
28
30
  export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
29
31
  export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
30
32
  export { parseWorkers } from "../config/workers.js";
33
+ export { parseRetries } from "../config/retries.js";
31
34
  export type { RunEvent } from "./reporter.js";
32
35
  export type { Modifier, TestError, TestResult, TestSummary, } from "../types/test.js";
33
36
  export type { ResolvedGroup } from "./groups.js";
@@ -35,10 +35,11 @@ export async function runTests(options = {}) {
35
35
  }
36
36
  /** Imports the project's specs without running anything and lists the tests the options select */
37
37
  export async function listTests(options = {}) {
38
- return inProject(options, async (runner) => (await runner.register()).map(({ name, file, modifier }) => ({
38
+ return inProject(options, async (runner) => (await runner.register()).map(({ name, file, modifier, reason }) => ({
39
39
  name,
40
40
  file,
41
41
  ...(modifier && { modifier }),
42
+ ...(reason !== undefined && { reason }),
42
43
  })));
43
44
  }
44
45
  /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
@@ -47,4 +48,5 @@ export async function listGroups(options = {}) {
47
48
  }
48
49
  export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
49
50
  export { parseWorkers } from "../config/workers.js";
51
+ export { parseRetries } from "../config/retries.js";
50
52
  export { DEFAULT_PORT } from "./ports.js";
@@ -1,4 +1,5 @@
1
1
  import type { RegisteredTestCase } from "../types/test.js";
2
+ import type { TestSkip } from "./reporter.js";
2
3
  /** Thrown by `test.skip(condition, reason)` and `test.fixme(condition, reason)` inside a test: the test ends as skipped */
3
4
  export declare class SkipSignal extends Error {
4
5
  readonly annotation: "skip" | "fixme";
@@ -7,4 +8,4 @@ export declare class SkipSignal extends Error {
7
8
  }
8
9
  export declare function isSkipSignal(error: unknown): error is SkipSignal;
9
10
  /** Why a test does not run at all: it is marked `skip` or `fixme`, itself or by a `describe` around it */
10
- export declare function staticSkip(test: Pick<RegisteredTestCase, "modifier">): "skip" | "fixme" | undefined;
11
+ export declare function staticSkip(test: Pick<RegisteredTestCase, "modifier" | "reason">): TestSkip | undefined;
@@ -12,7 +12,8 @@ export function isSkipSignal(error) {
12
12
  }
13
13
  /** Why a test does not run at all: it is marked `skip` or `fixme`, itself or by a `describe` around it */
14
14
  export function staticSkip(test) {
15
- return test.modifier === "skip" || test.modifier === "fixme"
16
- ? test.modifier
15
+ const { modifier, reason } = test;
16
+ return modifier === "skip" || modifier === "fixme"
17
+ ? { annotation: modifier, ...(reason !== undefined && { reason }) }
17
18
  : undefined;
18
19
  }
@@ -1,19 +1,25 @@
1
1
  import type { EachHook, Modifier, Suite } from "../types/test.js";
2
2
  /** The four hook lists a suite can hold */
3
3
  export type HookKind = "beforeEach" | "afterEach" | "beforeAll" | "afterAll";
4
- export declare function newSuite(title?: string, parent?: Suite, modifier?: Modifier): Suite;
4
+ export declare function newSuite(title?: string, parent?: Suite, modifier?: Modifier, reason?: string): Suite;
5
5
  /** The suite a test or hook written now belongs to: the innermost open `describe`, else the file's own */
6
6
  export declare function currentSuite(file: string): Suite;
7
7
  /** Opens a `describe` block under the current suite; `leaveSuite` closes it */
8
- export declare function enterSuite(file: string, title: string, modifier?: Modifier): Suite;
8
+ export declare function enterSuite(file: string, title: string, modifier?: Modifier, reason?: string): Suite;
9
9
  export declare function leaveSuite(): void;
10
10
  /** Forgets every suite: a run registers its specs afresh */
11
11
  export declare function resetSuites(): void;
12
12
  /**
13
13
  * What applies to a test: the innermost `skip` or `fixme` (the test's own, then its suites', inside out), else
14
- * `only` when the test or any suite around it is focused. A skip beats a focus.
14
+ * `only` when the test or any suite around it is focused. A skip beats a focus. The reason comes with the skip
15
+ * or fixme that applies.
15
16
  */
16
- export declare function modifierOf(own: Modifier | undefined, suite: Suite): Modifier | undefined;
17
+ export declare function modifierOf(own: Mark, suite: Suite): Mark;
18
+ /** A modifier and the reason given with it */
19
+ export interface Mark {
20
+ modifier?: Modifier;
21
+ reason?: string;
22
+ }
17
23
  /** The suites from the file's root down to `suite` */
18
24
  export declare function suiteChain(suite: Suite): Suite[];
19
25
  /** The `beforeEach` hooks of a test: outermost suite first, in the order they were written */
@@ -2,11 +2,12 @@
2
2
  const roots = new Map();
3
3
  /** The `describe` blocks being registered, outermost first */
4
4
  const open = [];
5
- export function newSuite(title, parent, modifier) {
5
+ export function newSuite(title, parent, modifier, reason) {
6
6
  return {
7
7
  ...(title !== undefined && { title }),
8
8
  ...(parent && { parent }),
9
9
  ...(modifier && { modifier }),
10
+ ...(reason !== undefined && { reason }),
10
11
  };
11
12
  }
12
13
  /** The suite a test or hook written now belongs to: the innermost open `describe`, else the file's own */
@@ -22,8 +23,8 @@ export function currentSuite(file) {
22
23
  return root;
23
24
  }
24
25
  /** Opens a `describe` block under the current suite; `leaveSuite` closes it */
25
- export function enterSuite(file, title, modifier) {
26
- const suite = newSuite(title, currentSuite(file), modifier);
26
+ export function enterSuite(file, title, modifier, reason) {
27
+ const suite = newSuite(title, currentSuite(file), modifier, reason);
27
28
  open.push(suite);
28
29
  return suite;
29
30
  }
@@ -37,18 +38,26 @@ export function resetSuites() {
37
38
  }
38
39
  /**
39
40
  * What applies to a test: the innermost `skip` or `fixme` (the test's own, then its suites', inside out), else
40
- * `only` when the test or any suite around it is focused. A skip beats a focus.
41
+ * `only` when the test or any suite around it is focused. A skip beats a focus. The reason comes with the skip
42
+ * or fixme that applies.
41
43
  */
42
44
  export function modifierOf(own, suite) {
43
- let focused = own === "only";
44
- let found = own === "only" ? undefined : own;
45
- for (let s = suite; s && !found; s = s.parent) {
45
+ if (own.modifier === "skip" || own.modifier === "fixme")
46
+ return markOf(own);
47
+ let focused = own.modifier === "only";
48
+ for (let s = suite; s; s = s.parent) {
46
49
  if (s.modifier === "only")
47
50
  focused = true;
48
- else
49
- found = s.modifier;
51
+ else if (s.modifier)
52
+ return markOf(s);
50
53
  }
51
- return found ?? (focused ? "only" : undefined);
54
+ return focused ? { modifier: "only" } : {};
55
+ }
56
+ function markOf({ modifier, reason }) {
57
+ return {
58
+ ...(modifier && { modifier }),
59
+ ...(reason !== undefined && { reason }),
60
+ };
52
61
  }
53
62
  /** The suites from the file's root down to `suite` */
54
63
  export function suiteChain(suite) {
@@ -14,6 +14,8 @@ import { concurrency, planItems, poolMode, runPooled, serialKeyOf, } from "./poo
14
14
  import { allHookFixtures, runOneTest } from "./run-test.js";
15
15
  import { staticSkip } from "./skip.js";
16
16
  import { browsersOfRun, loadSpecs, selectTests } from "./select.js";
17
+ import { resolveRetries } from "./retries-option.js";
18
+ import { runWithRetries } from "./retry.js";
17
19
  import { resolveWorkers } from "./workers-option.js";
18
20
  import { WorkerPool } from "./worker-pool.js";
19
21
  import { processWorkerFactory } from "./worker-process.js";
@@ -75,6 +77,12 @@ export default class TestRunner {
75
77
  env: process.env.SAMURAI_WORKERS,
76
78
  config: config.workers,
77
79
  });
80
+ // A bad `retries` is an error here too, before any browser starts
81
+ const retries = resolveRetries({
82
+ flag: this.options.retries,
83
+ env: process.env.SAMURAI_RETRIES,
84
+ config: config.retries,
85
+ });
78
86
  const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir));
79
87
  // A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
80
88
  const lanes = concurrency(plan.items);
@@ -97,7 +105,7 @@ export default class TestRunner {
97
105
  if (mode === "pool") {
98
106
  const workers = Math.min(requested, lanes);
99
107
  reporter.onStart(plan.planned.length, workers);
100
- const pool = this.createPool(workers);
108
+ const pool = this.createPool(workers, retries);
101
109
  try {
102
110
  await runPooled(pool, plan.planned, plan.items, reporter, signal);
103
111
  }
@@ -115,9 +123,9 @@ export default class TestRunner {
115
123
  try {
116
124
  for (const browser of browsers) {
117
125
  for (const test of selected) {
118
- const annotation = staticSkip(test);
119
- if (annotation) {
120
- reporter.onTestSkipped(test, browser, { annotation });
126
+ const skip = staticSkip(test);
127
+ if (skip) {
128
+ reporter.onTestSkipped(test, browser, skip);
121
129
  continue;
122
130
  }
123
131
  if (signal?.aborted) {
@@ -128,7 +136,7 @@ export default class TestRunner {
128
136
  });
129
137
  continue;
130
138
  }
131
- await runOneTest(test, browser, this.run, this.options, loaded.srcDir, reporter, { allHooks });
139
+ await runWithRetries(test, browser, retries, signal, (sink, attempt) => runOneTest(test, browser, this.run, this.options, loaded.srcDir, sink, { allHooks, attempt }), reporter);
132
140
  }
133
141
  }
134
142
  }
@@ -145,7 +153,7 @@ export default class TestRunner {
145
153
  fromConfig !== undefined);
146
154
  }
147
155
  /** The pool of a run: up to `workers` worker processes that read the same project and settings as this run */
148
- createPool(workers) {
156
+ createPool(workers, retries) {
149
157
  const { settings } = this.run;
150
158
  const { reportPath, headless, port } = this.options;
151
159
  const options = {
@@ -163,6 +171,7 @@ export default class TestRunner {
163
171
  ...(typeof reportPath === "string" && { reportPath }),
164
172
  files: this.testFiles,
165
173
  basePort: port ?? DEFAULT_PORT,
174
+ ...(retries > 0 && { retries }),
166
175
  },
167
176
  };
168
177
  return (this.project.createPool ?? ((o) => new WorkerPool(o)))(options);
@@ -4,6 +4,7 @@ export type PoolEvent = {
4
4
  type: "test-start";
5
5
  item: WorkItem;
6
6
  worker: number;
7
+ retry?: number;
7
8
  } | {
8
9
  type: "test-end";
9
10
  item: WorkItem;
@@ -187,6 +187,7 @@ export class WorkerPool {
187
187
  type: "test-start",
188
188
  item: busy.entry.item,
189
189
  worker: slot.index,
190
+ ...(message.retry && { retry: message.retry }),
190
191
  });
191
192
  case "test-end":
192
193
  if (busy?.id !== message.id)