@itmetsam/samurai-framework 0.15.0 → 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 (47) hide show
  1. package/dist/cli/args.d.ts +4 -1
  2. package/dist/cli/args.js +7 -2
  3. package/dist/cli/console-reporter.js +11 -2
  4. package/dist/cli/flow.js +5 -0
  5. package/dist/cli/main.js +1 -0
  6. package/dist/cli/usage.d.ts +1 -1
  7. package/dist/cli/usage.js +2 -0
  8. package/dist/config/config.js +3 -0
  9. package/dist/config/retries.d.ts +8 -0
  10. package/dist/config/retries.js +19 -0
  11. package/dist/flows/run/node-pool.d.ts +9 -0
  12. package/dist/flows/run/node-pool.js +27 -0
  13. package/dist/flows/run/project-options.d.ts +44 -0
  14. package/dist/flows/run/project-options.js +8 -0
  15. package/dist/flows/run/project.d.ts +4 -40
  16. package/dist/flows/run/project.js +12 -33
  17. package/dist/recorder/candidates.d.ts +2 -1
  18. package/dist/recorder/candidates.js +44 -1
  19. package/dist/recorder/capture.d.ts +4 -2
  20. package/dist/recorder/capture.js +12 -4
  21. package/dist/recorder/page-messages.d.ts +2 -0
  22. package/dist/recorder/ranking.d.ts +2 -0
  23. package/dist/recorder/ranking.js +8 -1
  24. package/dist/recorder/recorder.js +5 -2
  25. package/dist/runner/options.d.ts +6 -0
  26. package/dist/runner/pooled-run.js +5 -4
  27. package/dist/runner/reporter.d.ts +6 -4
  28. package/dist/runner/reporter.js +7 -1
  29. package/dist/runner/retries-option.d.ts +10 -0
  30. package/dist/runner/retries-option.js +12 -0
  31. package/dist/runner/retry.d.ts +13 -0
  32. package/dist/runner/retry.js +43 -0
  33. package/dist/runner/run-test.d.ts +2 -0
  34. package/dist/runner/run-test.js +8 -2
  35. package/dist/runner/run.d.ts +1 -0
  36. package/dist/runner/run.js +1 -0
  37. package/dist/runner/test-runner.js +12 -3
  38. package/dist/runner/worker-pool.d.ts +1 -0
  39. package/dist/runner/worker-pool.js +1 -0
  40. package/dist/runner/worker-protocol.d.ts +3 -0
  41. package/dist/runner/worker-session.js +6 -3
  42. package/dist/runner/worker.js +3 -0
  43. package/dist/steps/model.d.ts +3 -0
  44. package/dist/steps/parse.js +30 -21
  45. package/dist/types/config.d.ts +7 -0
  46. package/dist/types/test.d.ts +12 -2
  47. package/package.json +2 -1
@@ -12,6 +12,7 @@ export declare function parseCommandLine(argv: string[]): {
12
12
  headless?: boolean | undefined;
13
13
  port?: string | undefined;
14
14
  workers?: string | undefined;
15
+ retries?: string | undefined;
15
16
  grep?: string | undefined;
16
17
  group?: string | undefined;
17
18
  browser?: string[] | undefined;
@@ -40,13 +41,15 @@ export interface RunnerFlags {
40
41
  port?: number;
41
42
  /** `--workers`: a whole number or a percentage like `50%`, as typed */
42
43
  workers?: string;
44
+ /** `--retries`: a whole number from 0, as typed */
45
+ retries?: string;
43
46
  grep?: string;
44
47
  group?: string;
45
48
  files?: string[];
46
49
  /** `--browser`, repeated for more than one; replaces the config's `browsers` for this run */
47
50
  browsers?: SupportedBrowser[];
48
51
  }
49
- /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (both repeatable) from `argv`, or from values that were parsed already */
52
+ /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--retries <n>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (both repeatable) from `argv`, or from values that were parsed already */
50
53
  export declare function parseRunnerFlags(source: string[] | CommandValues): RunnerFlags;
51
54
  /** Reads `--env`, `--timeout` and `--expect-timeout` from `argv` (or values parsed already); the environment falls back to SAMURAI_ENV */
52
55
  export declare function parseRunOverrides(source: string[] | CommandValues, env?: Partial<NodeJS.ProcessEnv>): EnvAndTimeouts;
package/dist/cli/args.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
- import { checkBrowserList, parseWorkers, } from "../runner/run.js";
2
+ import { checkBrowserList, parseRetries, parseWorkers, } from "../runner/run.js";
3
3
  const FLAGS = {
4
4
  env: { type: "string" },
5
5
  timeout: { type: "string" },
@@ -7,6 +7,7 @@ const FLAGS = {
7
7
  headless: { type: "boolean" },
8
8
  port: { type: "string" },
9
9
  workers: { type: "string" },
10
+ retries: { type: "string" },
10
11
  grep: { type: "string" },
11
12
  group: { type: "string" },
12
13
  browser: { type: "string", multiple: true },
@@ -34,7 +35,7 @@ export function parseCommandLine(argv) {
34
35
  });
35
36
  }
36
37
  const valuesOf = (source) => Array.isArray(source) ? parseCommandLine(source).values : source;
37
- /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (both repeatable) from `argv`, or from values that were parsed already */
38
+ /** Reads `--headless`, `--port <n>`, `--workers <n|n%>`, `--retries <n>`, `--grep <text>`, `--group <name>`, `--file <spec>` and `--browser <name>` (both repeatable) from `argv`, or from values that were parsed already */
38
39
  export function parseRunnerFlags(source) {
39
40
  const values = valuesOf(source);
40
41
  const flags = {};
@@ -51,6 +52,10 @@ export function parseRunnerFlags(source) {
51
52
  parseWorkers(values.workers, "--workers");
52
53
  flags.workers = values.workers;
53
54
  }
55
+ if (values.retries !== undefined) {
56
+ parseRetries(values.retries, "--retries");
57
+ flags.retries = values.retries;
58
+ }
54
59
  if (values.grep !== undefined)
55
60
  flags.grep = values.grep;
56
61
  if (values.group !== undefined)
@@ -1,5 +1,13 @@
1
1
  import { painter } from "./paint.js";
2
2
  const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`;
3
+ /** What a result says about its retries: `passed on retry 2`, `failed after 2 retries`, or nothing */
4
+ function retried(result) {
5
+ if (!("retry" in result) || !result.retry)
6
+ return undefined;
7
+ if (result.status === "success")
8
+ return `passed on retry ${result.retry}`;
9
+ return `failed after ${result.retry} ${result.retry === 1 ? "retry" : "retries"}`;
10
+ }
3
11
  /** Prints a run for a person: one line per test as it ends, then every failure in full and a count */
4
12
  export class ConsoleReporter {
5
13
  constructor({ write, color }) {
@@ -30,7 +38,7 @@ export class ConsoleReporter {
30
38
  }
31
39
  line(name, result, browser) {
32
40
  const took = "duration" in result
33
- ? ` ${this.paint(90, `(${seconds(result.duration)})`)}`
41
+ ? ` ${this.paint(90, `(${[seconds(result.duration), retried(result)].filter(Boolean).join(", ")})`)}`
34
42
  : "";
35
43
  if (result.status === "skipped")
36
44
  return this.paint(90, `- ${name}${this.tag(browser)} (${result.annotation === "fixme" ? "fixme" : "skipped"}${result.reason ? `: ${result.reason}` : ""})`);
@@ -54,9 +62,10 @@ export class ConsoleReporter {
54
62
  this.write(` ${this.paint(90, test.file)}\n`);
55
63
  }
56
64
  const passed = summary.tests.length - failed.length - skipped.length;
65
+ const flaky = summary.tests.filter((test) => "flaky" in test && test.flaky).length;
57
66
  const parts = [
58
67
  failed.length > 0 && this.paint(31, `${failed.length} failed`),
59
- this.paint(32, `${passed} passed`),
68
+ this.paint(32, `${passed} passed${flaky > 0 ? ` (${flaky} flaky)` : ""}`),
60
69
  skipped.length > 0 && this.paint(90, `${skipped.length} skipped`),
61
70
  ].filter(Boolean);
62
71
  this.write(`\n${parts.join(", ")} ${this.paint(90, `(${seconds(summary.duration)})`)}\n`);
package/dist/cli/flow.js CHANGED
@@ -25,6 +25,7 @@ Options for run:
25
25
  --headless, --no-headless Run the browser without a window, or with one
26
26
  --port <n> Browser debugging port (default ${DEFAULT_PORT})
27
27
  --workers <n|n%> Worker processes the tests share (or SAMURAI_WORKERS; default half the CPUs)
28
+ --retries <n> Run a failing test again up to n times, each with a fresh browser (or SAMURAI_RETRIES; default 0)
28
29
 
29
30
  Each run writes result/flows/<id>.json.
30
31
  Exit code: 0 when every node that should run passed, 1 when a node failed or the run was cancelled,
@@ -228,6 +229,10 @@ export async function flowCommand(argv, io) {
228
229
  ...(io.env.SAMURAI_WORKERS && {
229
230
  workersEnv: io.env.SAMURAI_WORKERS,
230
231
  }),
232
+ ...(flags.retries !== undefined && { retries: flags.retries }),
233
+ ...(io.env.SAMURAI_RETRIES && {
234
+ retriesEnv: io.env.SAMURAI_RETRIES,
235
+ }),
231
236
  },
232
237
  });
233
238
  }
package/dist/cli/main.js CHANGED
@@ -48,6 +48,7 @@ export async function main(argv, io) {
48
48
  ...(flags.headless !== undefined && { headless: flags.headless }),
49
49
  ...(flags.port !== undefined && { port: flags.port }),
50
50
  ...(flags.workers !== undefined && { workers: flags.workers }),
51
+ ...(flags.retries !== undefined && { retries: flags.retries }),
51
52
  ...(flags.grep !== undefined && { grep: flags.grep }),
52
53
  ...(flags.group !== undefined && { group: flags.group }),
53
54
  ...(flags.files && { files: flags.files }),
@@ -1 +1 @@
1
- export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
1
+ export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n samurai browsers [command] List, install and remove the browsers SAMURAI launches\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups\n --browser <name> Run in this browser (firefox or chrome) instead of the config's\n browsers; repeat it for more than one\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n --workers <n|n%> Tests that run at the same time, each in a worker process: a number\n from 1, or a share of the CPUs like 50%. Default: the config's\n workers (or SAMURAI_WORKERS), else half the CPUs\n --retries <n> Run a failing test again up to n times, each with a fresh browser\n (or SAMURAI_RETRIES; default 0)\n\nOptions for record (also --env, --port, --json, --headless, --no-headless):\n --browser <name> Record in this browser (default: the first of the config's browsers)\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nsamurai browsers:\n list The pinned browsers and every build in the cache (the default)\n install [firefox|chrome|all]\n Download the pinned builds (default: all)\n uninstall <name> [--build <id>] | --unused\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
package/dist/cli/usage.js CHANGED
@@ -27,6 +27,8 @@ Options for run:
27
27
  --workers <n|n%> Tests that run at the same time, each in a worker process: a number
28
28
  from 1, or a share of the CPUs like 50%. Default: the config's
29
29
  workers (or SAMURAI_WORKERS), else half the CPUs
30
+ --retries <n> Run a failing test again up to n times, each with a fresh browser
31
+ (or SAMURAI_RETRIES; default 0)
30
32
 
31
33
  Options for record (also --env, --port, --json, --headless, --no-headless):
32
34
  --browser <name> Record in this browser (default: the first of the config's browsers)
@@ -3,6 +3,7 @@ import logger from "../logger/index.js";
3
3
  import { pathToFileURL } from "url";
4
4
  import { validateBrowserConfig } from "./browsers.js";
5
5
  import { validateForbidOnlyConfig } from "./forbid-only.js";
6
+ import { validateRetriesConfig } from "./retries.js";
6
7
  import { validateWorkersConfig } from "./workers.js";
7
8
  let activeProject;
8
9
  /** Makes `project` the one config, secrets, reports and downloads are resolved for; `undefined` ends it */
@@ -29,6 +30,7 @@ export async function loadConfig() {
29
30
  validateBrowserConfig(activeProject.config);
30
31
  validateWorkersConfig(activeProject.config);
31
32
  validateForbidOnlyConfig(activeProject.config);
33
+ validateRetriesConfig(activeProject.config);
32
34
  return activeProject.config;
33
35
  }
34
36
  const configUrl = path.join(projectDir(), "samurai.config.ts");
@@ -40,6 +42,7 @@ export async function loadConfig() {
40
42
  validateBrowserConfig(config.default);
41
43
  validateWorkersConfig(config.default);
42
44
  validateForbidOnlyConfig(config.default);
45
+ validateRetriesConfig(config.default);
43
46
  return config.default;
44
47
  }
45
48
  /** A project's config without keeping the project active (`withProject` is one at a time): the given one, else its samurai.config.ts */
@@ -0,0 +1,8 @@
1
+ import type { SamuraiTestConfig } from "../types/config.js";
2
+ /**
3
+ * Reads a `retries` value: a whole number from 0, as a number or as text. `where` names the source (`--retries`,
4
+ * `SAMURAI_RETRIES`, a config key) in the error.
5
+ */
6
+ export declare function parseRetries(value: unknown, where: string): number;
7
+ /** Checks the `retries` key of a config. Throws an Error that names the key */
8
+ export declare function validateRetriesConfig(config: SamuraiTestConfig): void;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Reads a `retries` value: a whole number from 0, as a number or as text. `where` names the source (`--retries`,
3
+ * `SAMURAI_RETRIES`, a config key) in the error.
4
+ */
5
+ export function parseRetries(value, where) {
6
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0)
7
+ return value;
8
+ if (typeof value === "string" && /^\s*\d+\s*$/.test(value))
9
+ return Number(value.trim());
10
+ throw new Error(`${where} must be a whole number from 0, got "${String(value)}"`);
11
+ }
12
+ /** Checks the `retries` key of a config. Throws an Error that names the key */
13
+ export function validateRetriesConfig(config) {
14
+ if (config.retries === undefined)
15
+ return;
16
+ if (typeof config.retries !== "number")
17
+ throw new Error(`retries must be a whole number from 0, got "${String(config.retries)}"`);
18
+ parseRetries(config.retries, "retries");
19
+ }
@@ -0,0 +1,9 @@
1
+ import type { RunEvent } from "../../runner/reporter.js";
2
+ import type { WorkerPool } from "../../runner/worker-pool.js";
3
+ import type { WorkItem } from "../../runner/worker-protocol.js";
4
+ import type { SupportedBrowser } from "../../types/browser.js";
5
+ import type { NodeTest } from "./events.js";
6
+ /** The work items of a node: each test in each browser, the browsers one after the other */
7
+ export declare function itemsOf(tests: NodeTest[], browsers: readonly SupportedBrowser[]): WorkItem[];
8
+ /** Runs the tests of a node on the pool and reports them as a run would */
9
+ export declare function runOnPool(pool: Pick<WorkerPool, "run">, items: WorkItem[], onEvent: (event: RunEvent) => void, signal: AbortSignal): Promise<"passed" | "failed" | "cancelled">;
@@ -0,0 +1,27 @@
1
+ /** The work items of a node: each test in each browser, the browsers one after the other */
2
+ export function itemsOf(tests, browsers) {
3
+ return browsers
4
+ .flatMap((browser) => tests.map((t) => ({ file: t.file, testName: t.name, browser })))
5
+ .map((item, index) => ({ ...item, index }));
6
+ }
7
+ /** Runs the tests of a node on the pool and reports them as a run would */
8
+ export async function runOnPool(pool, items, onEvent, signal) {
9
+ const results = await pool.run(items, {
10
+ signal,
11
+ onEvent: (event) => onEvent({
12
+ type: event.type,
13
+ name: event.item.testName,
14
+ file: event.item.file,
15
+ browser: event.item.browser,
16
+ ...(event.type === "test-start" &&
17
+ event.retry && { retry: event.retry }),
18
+ ...(event.type === "test-end" && { result: event.result }),
19
+ }),
20
+ });
21
+ if (signal.aborted)
22
+ return "cancelled";
23
+ // A skipped test is not a failure
24
+ return results.every((r) => r.status === "success" || r.status === "skipped")
25
+ ? "passed"
26
+ : "failed";
27
+ }
@@ -0,0 +1,44 @@
1
+ import type { ProjectOptions } from "../../runner/project.js";
2
+ import type { PoolOptions, WorkerPool } from "../../runner/worker-pool.js";
3
+ import type { runTests } from "../../runner/run.js";
4
+ import type { FlowFile, FlowProblem } from "../core/index.js";
5
+ import type { FlowEvent } from "./events.js";
6
+ /** The flow has errors; nothing was run */
7
+ export declare class FlowCheckError extends Error {
8
+ readonly problems: FlowProblem[];
9
+ constructor(problems: FlowProblem[]);
10
+ }
11
+ export interface RunFlowInProjectOptions extends ProjectOptions {
12
+ /** The flow to run... */
13
+ flow?: FlowFile;
14
+ /** ...or the id of one in `<project>/flows` */
15
+ flowId?: string;
16
+ /** Beats the flow's `envDefault`, the config's `defaultEnvironment` and the first environment */
17
+ environment?: string;
18
+ signal?: AbortSignal;
19
+ onEvent?: (event: FlowEvent) => void;
20
+ /** `"fail"` (default): a flow with a node that can't run is refused. `"skip"`: those nodes are skipped. */
21
+ unsupported?: "fail" | "skip";
22
+ port?: number;
23
+ /**
24
+ * How many worker processes the tests of the whole run share: a number, or a percentage like `"50%"`. Unset
25
+ * takes `SAMURAI_WORKERS` (`workersEnv`), then the config's `workers`, then half the CPUs. 1 runs each Test
26
+ * and Group node through `runTests` in this process; more starts one pool that every node, and every branch
27
+ * of a Parallel node, takes its workers from
28
+ */
29
+ workers?: number | string;
30
+ /** The value of `SAMURAI_WORKERS`, when set */
31
+ workersEnv?: string;
32
+ /** How many times a failing test is run again: a number, or numeric text like the command line's `--retries`. Unset takes `SAMURAI_RETRIES` (`retriesEnv`), then the config's `retries`, then 0 */
33
+ retries?: number | string;
34
+ /** The value of `SAMURAI_RETRIES`, when set */
35
+ retriesEnv?: string;
36
+ /** @internal Replaces the worker pool (tests) */
37
+ createPool?: (options: PoolOptions) => Pick<WorkerPool, "run" | "dispose">;
38
+ /** @internal Replaces the runner of each Test and Group node (tests) */
39
+ testRunner?: typeof runTests;
40
+ /** @internal */
41
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
42
+ }
43
+ /** What `runFlowInProject` takes when the flow itself is given separately: everything but `flow` and `flowId` */
44
+ export type FlowRunOptions = Omit<RunFlowInProjectOptions, "flow" | "flowId">;
@@ -0,0 +1,8 @@
1
+ /** The flow has errors; nothing was run */
2
+ export class FlowCheckError extends Error {
3
+ constructor(problems) {
4
+ super(`This flow can't run:\n${problems.map((p) => ` - ${p.message}`).join("\n")}`);
5
+ this.problems = problems;
6
+ this.name = "FlowCheckError";
7
+ }
8
+ }
@@ -1,46 +1,11 @@
1
1
  import { type FlowFile, type FlowProblem } from "../core/index.js";
2
2
  import { type ResolvedGroup } from "../../runner/groups.js";
3
- import { type ProjectOptions } from "../../runner/project.js";
4
- import { runTests } from "../../runner/run.js";
5
- import { WorkerPool, type PoolOptions } from "../../runner/worker-pool.js";
6
3
  import type { SamuraiTestConfig } from "../../types/config.js";
7
- import type { FlowEvent, FlowSummary, NodeTest } from "./events.js";
4
+ import type { FlowSummary, NodeTest } from "./events.js";
5
+ import { FlowCheckError, type FlowRunOptions, type RunFlowInProjectOptions } from "./project-options.js";
8
6
  import type { SupportedBrowser } from "../../types/browser.js";
9
- /** The flow has errors; nothing was run */
10
- export declare class FlowCheckError extends Error {
11
- readonly problems: FlowProblem[];
12
- constructor(problems: FlowProblem[]);
13
- }
14
- export interface RunFlowInProjectOptions extends ProjectOptions {
15
- /** The flow to run... */
16
- flow?: FlowFile;
17
- /** ...or the id of one in `<project>/flows` */
18
- flowId?: string;
19
- /** Beats the flow's `envDefault`, the config's `defaultEnvironment` and the first environment */
20
- environment?: string;
21
- signal?: AbortSignal;
22
- onEvent?: (event: FlowEvent) => void;
23
- /** `"fail"` (default): a flow with a node that can't run is refused. `"skip"`: those nodes are skipped. */
24
- unsupported?: "fail" | "skip";
25
- port?: number;
26
- /**
27
- * How many worker processes the tests of the whole run share: a number, or a percentage like `"50%"`. Unset
28
- * takes `SAMURAI_WORKERS` (`workersEnv`), then the config's `workers`, then half the CPUs. 1 runs each Test
29
- * and Group node through `runTests` in this process; more starts one pool that every node, and every branch
30
- * of a Parallel node, takes its workers from
31
- */
32
- workers?: number | string;
33
- /** The value of `SAMURAI_WORKERS`, when set */
34
- workersEnv?: string;
35
- /** @internal Replaces the worker pool (tests) */
36
- createPool?: (options: PoolOptions) => Pick<WorkerPool, "run" | "dispose">;
37
- /** @internal Replaces the runner of each Test and Group node (tests) */
38
- testRunner?: typeof runTests;
39
- /** @internal */
40
- sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
41
- }
42
- /** What `runFlowInProject` takes when the flow itself is given separately: everything but `flow` and `flowId` */
43
- export type FlowRunOptions = Omit<RunFlowInProjectOptions, "flow" | "flowId">;
7
+ export { FlowCheckError };
8
+ export type { FlowRunOptions, RunFlowInProjectOptions };
44
9
  /** The environment: the option, else the flow's `envDefault` (when the project has it), else the config's default, else the first, else "default" */
45
10
  export declare function chooseEnvironment(config: SamuraiTestConfig, flow: FlowFile, option?: string): string;
46
11
  /** A test of the project as flows see it */
@@ -71,4 +36,3 @@ export declare function checkFlowInProject(options: Pick<RunFlowInProjectOptions
71
36
  * One at a time per process.
72
37
  */
73
38
  export declare function runFlowInProject(options: RunFlowInProjectOptions): Promise<FlowSummary>;
74
- export {};
@@ -3,6 +3,7 @@ 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
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,31 +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
- // A skipped test is not a failure
60
- return results.every((r) => r.status === "success" || r.status === "skipped")
61
- ? "passed"
62
- : "failed";
63
- }
64
35
  /** The tests, and the browsers, of each Test and Group node of a flow; a Test node uses the project's browsers */
65
36
  export function nodeTestsOf(flow, tests, groups) {
66
37
  const byId = new Map(tests.map((t) => [t.id, t]));
@@ -182,6 +153,12 @@ async function walkFlowInProject(options) {
182
153
  const files = [
183
154
  ...new Set(Object.values(nodeTests).flatMap((list) => list.map((t) => t.file))),
184
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
+ });
185
162
  // One pool for the whole run: the nodes, and the branches of a Parallel node, share its workers
186
163
  const pool = workers > 1
187
164
  ? (options.createPool ?? ((o) => new WorkerPool(o)))({
@@ -200,6 +177,7 @@ async function walkFlowInProject(options) {
200
177
  }),
201
178
  files,
202
179
  basePort: options.port ?? DEFAULT_PORT,
180
+ ...(retries > 0 && { retries }),
203
181
  },
204
182
  })
205
183
  : undefined;
@@ -230,6 +208,7 @@ async function walkFlowInProject(options) {
230
208
  expectTimeout: options.expectTimeout,
231
209
  }),
232
210
  ...(options.port !== undefined && { port: options.port }),
211
+ ...(retries > 0 && { retries }),
233
212
  ...(browsers && { browsers }),
234
213
  files: [...new Set(tests.map((t) => t.file))],
235
214
  testNames: tests.map((t) => t.name),
@@ -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
  }
@@ -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) */
@@ -76,12 +76,13 @@ export async function runPooled(pool, planned, items, reporter, signal) {
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 { 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, { annotation: 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,
@@ -28,6 +28,7 @@ export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[
28
28
  export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
29
29
  export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
30
30
  export { parseWorkers } from "../config/workers.js";
31
+ export { parseRetries } from "../config/retries.js";
31
32
  export type { RunEvent } from "./reporter.js";
32
33
  export type { Modifier, TestError, TestResult, TestSummary, } from "../types/test.js";
33
34
  export type { ResolvedGroup } from "./groups.js";
@@ -47,4 +47,5 @@ export async function listGroups(options = {}) {
47
47
  }
48
48
  export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
49
49
  export { parseWorkers } from "../config/workers.js";
50
+ export { parseRetries } from "../config/retries.js";
50
51
  export { DEFAULT_PORT } from "./ports.js";
@@ -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
  }
@@ -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)
@@ -20,6 +20,8 @@ export interface WorkerInit {
20
20
  timeout?: number;
21
21
  expectTimeout?: number;
22
22
  headless?: boolean;
23
+ /** How many times a failing test is run again in this worker. @default 0 */
24
+ retries?: number;
23
25
  /** Where the run's report goes when it is not the default; screenshots are kept beside it */
24
26
  reportPath?: string;
25
27
  /** Spec files to import, absolute */
@@ -48,6 +50,7 @@ export type FromWorker = {
48
50
  } | {
49
51
  type: "test-start";
50
52
  id: number;
53
+ retry?: number;
51
54
  } | {
52
55
  type: "test-end";
53
56
  id: number;
@@ -4,6 +4,7 @@ import { withProject } from "./project.js";
4
4
  import TestReporter from "./reporter.js";
5
5
  import { AllHooks } from "./all-hooks.js";
6
6
  import { allHookFixtures, runOneTest } from "./run-test.js";
7
+ import { runWithRetries } from "./retry.js";
7
8
  import { loadSpecs } from "./select.js";
8
9
  /**
9
10
  * Opens the project for a worker and keeps it open until `close`: `withProject` holds the config, environment
@@ -42,19 +43,21 @@ export function openSession(init) {
42
43
  output: false,
43
44
  onEvent,
44
45
  });
45
- await runOneTest(test, browser, run, {
46
+ const options = {
46
47
  ...(headless !== undefined && { headless }),
47
48
  ...(init.reportPath !== undefined && {
48
49
  reportPath: init.reportPath,
49
50
  }),
50
51
  port,
51
52
  signal,
52
- }, loaded.srcDir, reporter, {
53
+ };
54
+ await runWithRetries(test, browser, init.retries ?? 0, signal, (sink, attempt) => runOneTest(test, browser, run, options, loaded.srcDir, sink, {
53
55
  port,
54
56
  profileDir: workerProfileDir(browser, workerIndex),
55
57
  workerIndex,
56
58
  allHooks,
57
- });
59
+ attempt,
60
+ }), reporter);
58
61
  },
59
62
  async close() {
60
63
  for (const error of await allHooks.closeAll())
@@ -43,6 +43,9 @@ export function handleMessages(channel, { open, exit }) {
43
43
  current = new AbortController();
44
44
  try {
45
45
  await session.runTest(test, item.browser, current.signal, (event) => {
46
+ // The first start was sent above; a test run again starts again, saying which attempt
47
+ if (event.type === "test-start" && event.retry)
48
+ channel.send({ type: "test-start", id, retry: event.retry });
46
49
  if (event.type !== "test-end")
47
50
  return;
48
51
  ended = true;
@@ -1,3 +1,4 @@
1
+ import type { Modifier } from "../types/test.js";
1
2
  /** One call of a locator expression, as written in a spec: `page.getByRole("button", { name: "Save" })` */
2
3
  export type LocatorCall = {
3
4
  method: "locator";
@@ -155,4 +156,6 @@ export interface ParsedTest {
155
156
  at: number;
156
157
  shape: "list" | "empty" | "none";
157
158
  };
159
+ /** `skip` or `fixme` (not run), or `only` (focused), as the runner applies it: a modifier on a `describe` counts */
160
+ modifier?: Modifier;
158
161
  }
@@ -44,32 +44,40 @@ function callbackBody(node) {
44
44
  ? node.body
45
45
  : undefined;
46
46
  }
47
- /** `describe.skip`, `describe.fixme` and `describe.only`: tests written inside keep their place in the title */
48
- function isDescribeWithModifier(expression) {
49
- return (ts.isPropertyAccessExpression(expression) &&
47
+ /** `test(...)` or `describe(...)` with its modifier: `test.skip(...)` gives `{ name: "test", modifier: "skip" }` */
48
+ function calleeOf(expression) {
49
+ if (ts.isIdentifier(expression))
50
+ return { name: expression.text };
51
+ if (ts.isPropertyAccessExpression(expression) &&
50
52
  ts.isIdentifier(expression.expression) &&
51
- expression.expression.text === "describe" &&
52
- ["skip", "fixme", "only"].includes(expression.name.text));
53
+ ["skip", "fixme", "only"].includes(expression.name.text))
54
+ return {
55
+ name: expression.expression.text,
56
+ modifier: expression.name.text,
57
+ };
58
+ return undefined;
59
+ }
60
+ /** What the runner applies to a test (see `modifierOf` in the runner): the innermost `skip` or `fixme`, else `only` */
61
+ function effectiveModifier(own, describes) {
62
+ const chain = [own, ...[...describes].reverse()];
63
+ return (chain.find((m) => m === "skip" || m === "fixme") ??
64
+ (chain.includes("only") ? "only" : undefined));
53
65
  }
54
66
  /** The tests in a spec file, with their steps and where each statement sits */
55
67
  export function parseSpec(source) {
56
68
  const sourceFile = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
57
69
  const tests = [];
58
- const visit = (node, titles) => {
59
- if (ts.isCallExpression(node) && isDescribeWithModifier(node.expression)) {
60
- // `describe.skip("Old", () => ...)`: a describe block for the names of the tests inside it
61
- const title = stringOf(node.arguments[0]);
62
- const body = callbackBody(node.arguments[1]);
63
- if (title !== undefined && body) {
64
- ts.forEachChild(body, (child) => visit(child, [...titles, title]));
65
- return;
66
- }
67
- }
68
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
70
+ const visit = (node, titles, describes) => {
71
+ const callee = ts.isCallExpression(node)
72
+ ? calleeOf(node.expression)
73
+ : undefined;
74
+ if (ts.isCallExpression(node) && callee) {
69
75
  const title = stringOf(node.arguments[0]);
70
76
  const body = callbackBody(node.arguments[1]);
71
- if (title !== undefined && body && node.expression.text === "test") {
77
+ if (title !== undefined && body && callee.name === "test") {
78
+ // `test.skip(condition, reason)` has no title and no body, so it stays a step of the test it is in
72
79
  const titlePath = [...titles, title];
80
+ const modifier = effectiveModifier(callee.modifier, describes);
73
81
  tests.push({
74
82
  titlePath,
75
83
  name: titlePath.join(" > "),
@@ -77,16 +85,17 @@ export function parseSpec(source) {
77
85
  bodyStart: body.getStart(sourceFile) + 1,
78
86
  bodyEnd: body.end - 1,
79
87
  fixtures: callbackFixtures(node.arguments[1], sourceFile),
88
+ ...(modifier && { modifier }),
80
89
  });
81
90
  return;
82
91
  }
83
- if (title !== undefined && body && node.expression.text === "describe") {
84
- ts.forEachChild(body, (child) => visit(child, [...titles, title]));
92
+ if (title !== undefined && body && callee.name === "describe") {
93
+ ts.forEachChild(body, (child) => visit(child, [...titles, title], [...describes, callee.modifier]));
85
94
  return;
86
95
  }
87
96
  }
88
- ts.forEachChild(node, (child) => visit(child, titles));
97
+ ts.forEachChild(node, (child) => visit(child, titles, describes));
89
98
  };
90
- visit(sourceFile, []);
99
+ visit(sourceFile, [], []);
91
100
  return tests;
92
101
  }
@@ -64,6 +64,13 @@ export interface SamuraiTestConfig {
64
64
  * @default half the CPUs
65
65
  */
66
66
  workers?: number | string;
67
+ /**
68
+ * How many times a failing test is run again, each time with a fresh browser. A test that passes on a retry is
69
+ * reported as flaky and does not fail the run; a run that is aborted is never retried. `--retries` and
70
+ * `SAMURAI_RETRIES` win over it.
71
+ * @default 0
72
+ */
73
+ retries?: number;
67
74
  /**
68
75
  * Refuse a run when a spec contains `test.only` or `describe.only`, so a forgotten focus cannot quietly shrink a
69
76
  * suite. The run stops before any test starts and lists the tests.
@@ -70,7 +70,7 @@ export interface PartialTestResult extends TestResultBase {
70
70
  export type TestResult =
71
71
  TestResultSuccess | TestResultFailed | TestResultSkipped | TestResultBase;
72
72
 
73
- interface TestResultSuccess extends TestResultIdentity {
73
+ interface TestResultSuccess extends TestResultIdentity, TestRetries {
74
74
  status: "success";
75
75
  duration: number;
76
76
  logs?: TestLogEntry[];
@@ -81,7 +81,7 @@ interface TestResultSuccess extends TestResultIdentity {
81
81
  }
82
82
 
83
83
  /** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
84
- interface TestResultFailed extends TestResultIdentity, TestError {
84
+ interface TestResultFailed extends TestResultIdentity, TestError, TestRetries {
85
85
  status: "failed";
86
86
  duration: number;
87
87
  logs?: TestLogEntry[];
@@ -90,6 +90,16 @@ interface TestResultFailed extends TestResultIdentity, TestError {
90
90
  screenshots?: { name: string; path: string }[];
91
91
  }
92
92
 
93
+ /** What a result says about the retries of its test; absent when it passed or failed on the first attempt */
94
+ export interface TestRetries {
95
+ /** The attempt this result is from, counting from 1: `retry: 1` is the first run again */
96
+ retry?: number;
97
+ /** The test passed after at least one failed attempt */
98
+ flaky?: true;
99
+ /** The errors of the failed attempts before this one, in order */
100
+ previousAttempts?: TestError[];
101
+ }
102
+
93
103
  /** A test that was not run: marked `skip` or `fixme`, or skipped from inside with `test.skip(condition)` */
94
104
  interface TestResultSkipped extends TestResultIdentity {
95
105
  status: "skipped";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {
@@ -53,6 +53,7 @@
53
53
  "scripts": {
54
54
  "dev": "tsx src/index.ts",
55
55
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-types.mjs",
56
+ "prepack": "npm run build",
56
57
  "test": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-timeout=10000 \"src/**/*.test.ts\"",
57
58
  "test:browser": "SAMURAI_LOG_LEVEL=${SAMURAI_LOG_LEVEL:-warn} tsx --import ./scripts/test-tmp.mjs --test --test-concurrency=1 --test-timeout=60000 \"src/**/*.browser-test.ts\"",
58
59
  "test:browser:chrome": "SAMURAI_TEST_BROWSER=chrome npm run test:browser",