@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
@@ -27,6 +27,11 @@ export declare function planItems(selected: RegisteredTestCase[], browsers: Supp
27
27
  * and `configGroups` are the same list, resolved and as written.
28
28
  */
29
29
  export declare function serialKeyOf(groups: ResolvedGroup[], configGroups: SamuraiGroup[] | undefined, srcDir: string): (test: RegisteredTestCase) => string | undefined;
30
+ /**
31
+ * How many items can run at the same time: each item alone counts one, and the items of one serial key together
32
+ * count one. More workers than this would only sit idle.
33
+ */
34
+ export declare function concurrency(items: WorkItem[]): number;
30
35
  /**
31
36
  * How a run goes: through the pool, in this process, or in this process because the workers could not read the
32
37
  * config (it was given as an object; a worker reads `samurai.config.ts` from the project folder)
@@ -37,7 +42,8 @@ export declare function poolMode(input: {
37
42
  inlineConfig: boolean;
38
43
  }): "pool" | "in-process" | "inline-config";
39
44
  /**
40
- * Runs the planned items on the pool and reports them as they happen. An item the pool answers without events
45
+ * Runs the planned items on the pool and reports them as they happen. Tests that do not run (skip, fixme) are
46
+ * reported first, here, and never reach a worker. An item the pool answers without events
41
47
  * (a queued one cut short by an abort, or one whose worker died before it began) is reported here at the end.
42
48
  */
43
49
  export declare function runPooled(pool: PooledRunner, planned: PlannedTest[], items: WorkItem[], reporter: TestReporter, signal?: AbortSignal): Promise<void>;
@@ -1,19 +1,25 @@
1
1
  import { idOf } from "./select.js";
2
+ import { staticSkip } from "./skip.js";
2
3
  /**
3
4
  * Lists the run: every selected test in the first browser, then every selected test in the next one. The
4
5
  * position in the list is the item's `index`. `serialKey` says which group makes a test run alone.
5
6
  */
6
7
  export function planItems(selected, browsers, serialKey) {
7
8
  const planned = browsers.flatMap((browser) => selected.map((test) => ({ test, browser })));
8
- const items = planned.map(({ test, browser }, index) => {
9
+ // A test that does not run (skip, fixme) is in the plan but not in the queue; `index` is its place in the plan
10
+ const items = planned.flatMap(({ test, browser }, index) => {
11
+ if (staticSkip(test))
12
+ return [];
9
13
  const key = serialKey(test);
10
- return {
11
- index,
12
- file: test.file,
13
- testName: test.name,
14
- browser,
15
- ...(key !== undefined && { serialKey: key }),
16
- };
14
+ return [
15
+ {
16
+ index,
17
+ file: test.file,
18
+ testName: test.name,
19
+ browser,
20
+ ...(key !== undefined && { serialKey: key }),
21
+ },
22
+ ];
17
23
  });
18
24
  return { items, planned };
19
25
  }
@@ -32,6 +38,21 @@ export function serialKeyOf(groups, configGroups, srcDir) {
32
38
  });
33
39
  return (test) => keys.get(idOf(test, srcDir));
34
40
  }
41
+ /**
42
+ * How many items can run at the same time: each item alone counts one, and the items of one serial key together
43
+ * count one. More workers than this would only sit idle.
44
+ */
45
+ export function concurrency(items) {
46
+ const keys = new Set();
47
+ let free = 0;
48
+ for (const item of items) {
49
+ if (item.serialKey === undefined)
50
+ free++;
51
+ else
52
+ keys.add(item.serialKey);
53
+ }
54
+ return free + keys.size;
55
+ }
35
56
  /**
36
57
  * How a run goes: through the pool, in this process, or in this process because the workers could not read the
37
58
  * config (it was given as an object; a worker reads `samurai.config.ts` from the project folder)
@@ -42,19 +63,26 @@ export function poolMode(input) {
42
63
  return input.inlineConfig ? "inline-config" : "pool";
43
64
  }
44
65
  /**
45
- * Runs the planned items on the pool and reports them as they happen. An item the pool answers without events
66
+ * Runs the planned items on the pool and reports them as they happen. Tests that do not run (skip, fixme) are
67
+ * reported first, here, and never reach a worker. An item the pool answers without events
46
68
  * (a queued one cut short by an abort, or one whose worker died before it began) is reported here at the end.
47
69
  */
48
70
  export async function runPooled(pool, planned, items, reporter, signal) {
49
71
  reporter.setOrder(planned);
72
+ for (const { test, browser } of planned) {
73
+ const annotation = staticSkip(test);
74
+ if (annotation)
75
+ reporter.onTestSkipped(test, browser, { annotation });
76
+ }
50
77
  const started = new Set();
51
78
  const ended = new Set();
52
- const begin = (index) => {
53
- 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)
54
82
  return;
55
83
  started.add(index);
56
84
  const { test, browser } = planned[index];
57
- reporter.onTestStart(test, browser);
85
+ reporter.onTestStart(test, browser, retry);
58
86
  };
59
87
  const end = (index, result) => {
60
88
  begin(index);
@@ -65,10 +93,11 @@ export async function runPooled(pool, planned, items, reporter, signal) {
65
93
  const results = await pool.run(items, {
66
94
  ...(signal && { signal }),
67
95
  onEvent: (event) => event.type === "test-start"
68
- ? begin(event.item.index)
96
+ ? begin(event.item.index, event.retry)
69
97
  : end(event.item.index, event.result),
70
98
  });
71
- results.forEach((result, index) => {
99
+ results.forEach((result, position) => {
100
+ const index = items[position].index;
72
101
  if (!ended.has(index))
73
102
  end(index, result);
74
103
  });
@@ -1,7 +1,9 @@
1
+ import path from "node:path";
1
2
  import { removeGeneratedFiles } from "../browser/generated-files.js";
2
3
  import { loadConfig, projectDir, setActiveProject } from "../config/config.js";
3
4
  import { resetSecrets } from "../config/mask.js";
4
5
  import { setRunSettings } from "../config/run-settings.js";
6
+ import { keepLogFiles } from "../logger/index.js";
5
7
  import { prepareRun } from "./prepare-run.js";
6
8
  let active = false;
7
9
  /**
@@ -14,6 +16,8 @@ export async function withProject(options, work) {
14
16
  active = true;
15
17
  const { projectDir: dir, config, dataDir, environment, timeout, expectTimeout, headless, } = options;
16
18
  const previousDir = projectDir();
19
+ // The log goes to the project's `logs/` folder, not to wherever this process was started
20
+ const releaseLogs = keepLogFiles(path.resolve(dir ?? previousDir));
17
21
  try {
18
22
  setActiveProject({ dir: dir ?? previousDir, config, dataDir });
19
23
  const loaded = config ?? (await loadConfig());
@@ -32,5 +36,6 @@ export async function withProject(options, work) {
32
36
  setRunSettings(undefined);
33
37
  setActiveProject(undefined);
34
38
  active = false;
39
+ releaseLogs();
35
40
  }
36
41
  }
@@ -1,7 +1,38 @@
1
- import type { RegisteredTestCase, TestCaseBase } from "../types/test.js";
2
- /** Groups the tests registered inside `fn` under `title`. `fn` runs immediately and must be synchronous. */
3
- export declare function describe(title: string, fn: () => void): void;
4
- export declare function test(title: string, fn: TestCaseBase["function"]): void;
1
+ import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase } from "../types/test.js";
2
+ type Body = TestCaseBase["function"];
3
+ /** `test(title, fn)`, `test.skip(title, fn)`, `test.skip(condition, reason)` and the other forms */
4
+ export interface TestFunction {
5
+ (title: string, fn: Body): void;
6
+ /** Registers a test that is not run */
7
+ skip: SkipFunction;
8
+ /** Registers a test that is not run because it is known to be broken */
9
+ fixme: SkipFunction;
10
+ /** Runs only this test (and the other `only` ones) */
11
+ only(title: string, fn: Body): void;
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 */
14
+ interface SkipFunction {
15
+ (title: string, fn: Body): void;
16
+ (condition: boolean, reason?: string): void;
17
+ }
18
+ /** `describe(title, fn)` and the same with a modifier on every test inside */
19
+ export interface DescribeFunction {
20
+ (title: string, fn: () => void): void;
21
+ skip(title: string, fn: () => void): void;
22
+ fixme(title: string, fn: () => void): void;
23
+ only(title: string, fn: () => void): void;
24
+ }
25
+ export declare const test: TestFunction;
26
+ export declare const describe: DescribeFunction;
27
+ /** Runs before each test of the `describe` block it is written in (of the file, at the top level) */
28
+ export declare function beforeEach(hook: EachHook): void;
29
+ /** Runs after each test of the `describe` block it is written in, also when the test failed */
30
+ export declare function afterEach(hook: EachHook): void;
31
+ /** Runs once per worker, before the first test of its `describe` block that the worker runs. It gets no page. */
32
+ export declare function beforeAll(hook: AllHook): void;
33
+ /** Runs once per worker, when the worker is done, for every `describe` block it ran a `beforeAll` for */
34
+ export declare function afterAll(hook: AllHook): void;
5
35
  export declare function registeredTests(): readonly RegisteredTestCase[];
6
36
  /** Forgets every registered test: a run registers its specs afresh */
7
37
  export declare function clearRegistry(): void;
38
+ export {};
@@ -1,11 +1,11 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import logger from "../logger/index.js";
3
+ import { SkipSignal } from "./skip.js";
4
+ import { addHook, currentSuite, enterSuite, leaveSuite, modifierOf, resetSuites, } from "./suite.js";
3
5
  const registered = [];
4
- /** Titles of the describe blocks being registered, outermost first */
5
- const titleStack = [];
6
- /** Groups the tests registered inside `fn` under `title`. `fn` runs immediately and must be synchronous. */
7
- export function describe(title, fn) {
8
- titleStack.push(title);
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);
9
9
  try {
10
10
  const result = fn();
11
11
  if (result instanceof Promise) {
@@ -14,24 +14,79 @@ export function describe(title, fn) {
14
14
  }
15
15
  }
16
16
  finally {
17
- titleStack.pop();
17
+ leaveSuite();
18
18
  }
19
19
  }
20
- export function test(title, fn) {
21
- const titlePath = [...titleStack, title];
20
+ function add(file, title, fn, modifier) {
21
+ const suite = currentSuite(file);
22
+ const titlePath = [...titlesOf(suite), title];
22
23
  const name = titlePath.join(" > ");
23
- registered.push({ name, titlePath, function: fn, file: callerFile() });
24
+ const effective = modifierOf(modifier, suite);
25
+ registered.push({
26
+ name,
27
+ titlePath,
28
+ function: fn,
29
+ file,
30
+ suite,
31
+ ...(effective && { modifier: effective }),
32
+ });
24
33
  logger.debug("Registered test %s", name);
25
34
  }
35
+ /** The `describe` titles from the file down to `suite` */
36
+ function titlesOf(suite) {
37
+ const titles = [];
38
+ for (let s = suite; s; s = s.parent)
39
+ if (s.title !== undefined)
40
+ titles.unshift(s.title);
41
+ return titles;
42
+ }
43
+ /** `test.skip` and `test.fixme`: a title and a body register a test with the modifier; a condition skips the running test */
44
+ function skipping(annotation) {
45
+ return ((first, second) => {
46
+ if (typeof first === "string") {
47
+ // Frames: Error, callerFile, this function, the spec
48
+ add(callerFile(), first, second, annotation);
49
+ return;
50
+ }
51
+ if (first)
52
+ throw new SkipSignal(annotation, second);
53
+ });
54
+ }
55
+ export const test = Object.assign((title, fn) => add(callerFile(), title, fn), {
56
+ skip: skipping("skip"),
57
+ fixme: skipping("fixme"),
58
+ only: (title, fn) => add(callerFile(), title, fn, "only"),
59
+ });
60
+ 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"),
63
+ only: (title, fn) => openDescribe(callerFile(), title, fn, "only"),
64
+ });
65
+ /** Runs before each test of the `describe` block it is written in (of the file, at the top level) */
66
+ export function beforeEach(hook) {
67
+ addHook(currentSuite(callerFile()), "beforeEach", hook);
68
+ }
69
+ /** Runs after each test of the `describe` block it is written in, also when the test failed */
70
+ export function afterEach(hook) {
71
+ addHook(currentSuite(callerFile()), "afterEach", hook);
72
+ }
73
+ /** Runs once per worker, before the first test of its `describe` block that the worker runs. It gets no page. */
74
+ export function beforeAll(hook) {
75
+ addHook(currentSuite(callerFile()), "beforeAll", hook);
76
+ }
77
+ /** Runs once per worker, when the worker is done, for every `describe` block it ran a `beforeAll` for */
78
+ export function afterAll(hook) {
79
+ addHook(currentSuite(callerFile()), "afterAll", hook);
80
+ }
26
81
  export function registeredTests() {
27
82
  return registered;
28
83
  }
29
84
  /** Forgets every registered test: a run registers its specs afresh */
30
85
  export function clearRegistry() {
31
86
  registered.length = 0;
32
- titleStack.length = 0;
87
+ resetSuites();
33
88
  }
34
- /** File of the code that called test(): stack lines are Error, callerFile, test, caller */
89
+ /** File of the code that called the entry point: stack lines are Error, callerFile, the entry point, the caller */
35
90
  function callerFile() {
36
91
  const caller = new Error().stack?.split("\n")[3] ?? "";
37
92
  const match = caller.match(/\((.+?):\d+:\d+\)$/) ?? caller.match(/at (.+?):\d+:\d+$/);
@@ -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,11 +38,18 @@ 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;
43
+ /** A test that does not run: reported as started and ended at once */
44
+ onTestSkipped(test: RegisteredTestCase, browser: SupportedBrowser, skip: TestSkip): void;
45
+ }
46
+ /** Why a test was skipped */
47
+ export interface TestSkip {
48
+ annotation: "skip" | "fixme";
49
+ reason?: string | undefined;
41
50
  }
42
51
  /** What a test's report entry lists besides its status */
43
- export interface TestEndExtras {
52
+ export interface TestEndExtras extends TestRetries {
44
53
  logs?: TestLogEntry[];
45
54
  logsDropped?: number;
46
55
  screenshots?: {
@@ -66,7 +75,8 @@ export default class TestReporter implements TestSink {
66
75
  }[]): void;
67
76
  onStart(total: number, workers?: number): void;
68
77
  onEnd(): Promise<TestSummary>;
69
- onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
78
+ onTestStart(test: RegisteredTestCase, browser: SupportedBrowser, retry?: number): void;
79
+ onTestSkipped(test: RegisteredTestCase, browser: SupportedBrowser, { annotation, reason }: TestSkip): void;
70
80
  /**
71
81
  * Ends a test that ran in a worker: the worker built the result (masked, with its own duration), this keeps the
72
82
  * start time from this process's clock, as the worker's clock has another origin
@@ -62,7 +62,7 @@ export default class TestReporter {
62
62
  environment: this.environment,
63
63
  browsers: this.browsers,
64
64
  tests,
65
- status: tests.every((r) => r.status === "success")
65
+ status: tests.every((r) => r.status === "success" || r.status === "skipped")
66
66
  ? "success"
67
67
  : "failed",
68
68
  };
@@ -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,28 @@ export default class TestReporter {
90
90
  name: test.name,
91
91
  file: test.file,
92
92
  browser,
93
+ ...(retry ? { retry } : {}),
94
+ });
95
+ }
96
+ onTestSkipped(test, browser, { annotation, reason }) {
97
+ const key = keyOf(test, browser);
98
+ // A test skipped from inside already started; one that never ran starts and ends at once
99
+ if (this.testResults.get(key)?.status !== "started")
100
+ this.onTestStart(test, browser);
101
+ const result = maskDeep({
102
+ ...this.testResults.get(key),
103
+ status: "skipped",
104
+ duration: 0,
105
+ annotation,
106
+ ...(reason !== undefined && { reason }),
107
+ });
108
+ this.testResults.set(key, result);
109
+ this.emit({
110
+ type: "test-end",
111
+ name: test.name,
112
+ file: test.file,
113
+ browser,
114
+ result,
93
115
  });
94
116
  }
95
117
  /**
@@ -124,6 +146,11 @@ export default class TestReporter {
124
146
  ...(extras?.logs && { logs: extras.logs }),
125
147
  ...(extras?.logsDropped && { logsDropped: extras.logsDropped }),
126
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
+ }),
127
154
  });
128
155
  this.testResults.set(key, result);
129
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
+ }
@@ -1,6 +1,7 @@
1
1
  import type { SupportedBrowser } from "../types/browser.js";
2
- import type { RegisteredTestCase } from "../types/test.js";
2
+ import type { AllHookFixtures, RegisteredTestCase } from "../types/test.js";
3
3
  import type { RunnerOptions } from "./options.js";
4
+ import type { AllHooks } from "./all-hooks.js";
4
5
  import type { PreparedRun } from "./prepare-run.js";
5
6
  import type { TestSink } from "./reporter.js";
6
7
  /** What a worker gives its tests that the main process does not: its own port and profile, and its number */
@@ -11,6 +12,12 @@ export interface WorkerResources {
11
12
  profileDir?: string;
12
13
  /** The `workerIndex` fixture. @default 0 */
13
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;
17
+ /** The `beforeAll` / `afterAll` hooks of this worker: kept across tests, closed when the worker is done */
18
+ allHooks?: AllHooks;
14
19
  }
20
+ /** What `beforeAll` and `afterAll` hooks get: no page, only what a run knows */
21
+ export declare function allHookFixtures(run: PreparedRun, browserName: SupportedBrowser, workerIndex?: number): AllHookFixtures;
15
22
  /** Runs one test in one browser and reports it: launch, run the body, judge the page's logs, close the browser */
16
23
  export declare function runOneTest(test: RegisteredTestCase, browserName: SupportedBrowser, run: PreparedRun, options: RunnerOptions, srcDir: string, reporter: TestSink, resources?: WorkerResources): Promise<void>;
@@ -9,6 +9,8 @@ import logger from "../logger/index.js";
9
9
  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
+ import { runWithEachHooks } from "./hooks.js";
13
+ import { isSkipSignal, staticSkip } from "./skip.js";
12
14
  import { testDirOf } from "./test-dir.js";
13
15
  import { toTestError } from "./test-error.js";
14
16
  /**
@@ -30,6 +32,16 @@ class TestOutcome {
30
32
  get isEnded() {
31
33
  return this.ended;
32
34
  }
35
+ /** The test skipped itself (`test.skip(condition)`): reported as skipped, not as a failure */
36
+ skip(signal) {
37
+ if (this.ended)
38
+ return;
39
+ this.ended = true;
40
+ this.reporter.onTestSkipped(this.test, this.browser, {
41
+ annotation: signal.annotation,
42
+ reason: signal.reason,
43
+ });
44
+ }
33
45
  finish(error, logs) {
34
46
  if (this.ended)
35
47
  return;
@@ -40,8 +52,24 @@ class TestOutcome {
40
52
  });
41
53
  }
42
54
  }
55
+ /** What `beforeAll` and `afterAll` hooks get: no page, only what a run knows */
56
+ export function allHookFixtures(run, browserName, workerIndex = 0) {
57
+ const { settings, secrets } = run;
58
+ return {
59
+ browserName,
60
+ workerIndex,
61
+ env: createEnvFixture(settings.environment, settings.variables),
62
+ secrets: createSecretsFixture(settings.environment, secrets),
63
+ };
64
+ }
43
65
  /** Runs one test in one browser and reports it: launch, run the body, judge the page's logs, close the browser */
44
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, { annotation: marked });
71
+ return;
72
+ }
45
73
  logger.verbose("Starting test: %s in %s", test.name, browserName);
46
74
  reporter.onTestStart(test, browserName);
47
75
  const outcome = new TestOutcome(reporter, test, browserName);
@@ -81,6 +109,8 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
81
109
  /** Launches the browser, runs the test body and reports how it went */
82
110
  const runBody = async () => {
83
111
  try {
112
+ // The `beforeAll` hooks come first, before a browser exists
113
+ await resources.allHooks?.ensure(test, browserName);
84
114
  const launched = await Browser.launch(browserName, {
85
115
  port: resources.port ?? options.port ?? DEFAULT_PORT,
86
116
  ...(resources.profileDir && { profileDir: resources.profileDir }),
@@ -101,7 +131,7 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
101
131
  browserGone();
102
132
  });
103
133
  page = launched.page;
104
- outcome.evidence = new Evidence(resultDir, `${browserName}/${testDirOf(test, srcDir)}`);
134
+ outcome.evidence = new Evidence(resultDir, `${browserName}/${testDirOf(test, srcDir)}${resources.attempt ? `/retry-${resources.attempt}` : ""}`);
105
135
  launched.page.setEvidence(outcome.evidence);
106
136
  const fixtures = {
107
137
  page: launched.page,
@@ -111,7 +141,7 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
111
141
  env: createEnvFixture(settings.environment, settings.variables),
112
142
  secrets: createSecretsFixture(settings.environment, secrets),
113
143
  };
114
- await test.function(fixtures);
144
+ await runWithEachHooks(test.suite, fixtures, timeoutController.signal, () => test.function(fixtures));
115
145
  // Already reported as timed out
116
146
  if (timeoutController.signal.aborted)
117
147
  return;
@@ -131,6 +161,8 @@ export async function runOneTest(test, browserName, run, options, srcDir, report
131
161
  catch (err) {
132
162
  if (timeoutController.signal.aborted)
133
163
  return;
164
+ if (isSkipSignal(err))
165
+ return outcome.skip(err);
134
166
  outcome.finish(outcome.browserClosed || err instanceof ConnectionClosedError
135
167
  ? {
136
168
  message: "The browser was closed while the test was running",
@@ -1,4 +1,4 @@
1
- import type { TestSummary } from "../types/test.js";
1
+ import type { Modifier, TestSummary } from "../types/test.js";
2
2
  import { type ProjectOptions } from "./project.js";
3
3
  import type { ResolvedGroup } from "./groups.js";
4
4
  import type { RunnerOptions } from "./options.js";
@@ -19,6 +19,8 @@ export interface FoundTest {
19
19
  /** Full name: `describe` titles and the test title joined with " > " */
20
20
  name: string;
21
21
  file: string;
22
+ /** `skip` or `fixme` (not run), or `only` (focused); absent for a plain test. A modifier on a `describe` counts. */
23
+ modifier?: Modifier;
22
24
  }
23
25
  /** Imports the project's specs without running anything and lists the tests the options select */
24
26
  export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
@@ -26,8 +28,9 @@ export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[
26
28
  export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
27
29
  export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
28
30
  export { parseWorkers } from "../config/workers.js";
31
+ export { parseRetries } from "../config/retries.js";
29
32
  export type { RunEvent } from "./reporter.js";
30
- export type { TestError, TestResult, TestSummary } from "../types/test.js";
33
+ export type { Modifier, TestError, TestResult, TestSummary, } from "../types/test.js";
31
34
  export type { ResolvedGroup } from "./groups.js";
32
35
  export type { RunnerOptions } from "./options.js";
33
36
  export type { ProjectOptions } from "./project.js";
@@ -35,7 +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 }) => ({ name, file })));
38
+ return inProject(options, async (runner) => (await runner.register()).map(({ name, file, modifier }) => ({
39
+ name,
40
+ file,
41
+ ...(modifier && { modifier }),
42
+ })));
39
43
  }
40
44
  /** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
41
45
  export async function listGroups(options = {}) {
@@ -43,4 +47,5 @@ export async function listGroups(options = {}) {
43
47
  }
44
48
  export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
45
49
  export { parseWorkers } from "../config/workers.js";
50
+ export { parseRetries } from "../config/retries.js";
46
51
  export { DEFAULT_PORT } from "./ports.js";
@@ -0,0 +1,10 @@
1
+ import type { RegisteredTestCase } from "../types/test.js";
2
+ /** Thrown by `test.skip(condition, reason)` and `test.fixme(condition, reason)` inside a test: the test ends as skipped */
3
+ export declare class SkipSignal extends Error {
4
+ readonly annotation: "skip" | "fixme";
5
+ readonly reason?: string | undefined;
6
+ constructor(annotation: "skip" | "fixme", reason?: string | undefined);
7
+ }
8
+ export declare function isSkipSignal(error: unknown): error is SkipSignal;
9
+ /** 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;