@itmetsam/samurai-framework 0.15.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/cli/args.d.ts +4 -1
  3. package/dist/cli/args.js +7 -2
  4. package/dist/cli/console-reporter.js +11 -2
  5. package/dist/cli/flow.js +5 -0
  6. package/dist/cli/main.js +2 -1
  7. package/dist/cli/usage.d.ts +1 -1
  8. package/dist/cli/usage.js +2 -0
  9. package/dist/config/config.js +3 -0
  10. package/dist/config/retries.d.ts +8 -0
  11. package/dist/config/retries.js +19 -0
  12. package/dist/flows/run/node-pool.d.ts +9 -0
  13. package/dist/flows/run/node-pool.js +27 -0
  14. package/dist/flows/run/project-options.d.ts +44 -0
  15. package/dist/flows/run/project-options.js +8 -0
  16. package/dist/flows/run/project.d.ts +4 -40
  17. package/dist/flows/run/project.js +12 -33
  18. package/dist/recorder/candidates.d.ts +2 -1
  19. package/dist/recorder/candidates.js +44 -1
  20. package/dist/recorder/capture.d.ts +4 -2
  21. package/dist/recorder/capture.js +12 -4
  22. package/dist/recorder/page-messages.d.ts +2 -0
  23. package/dist/recorder/ranking.d.ts +2 -0
  24. package/dist/recorder/ranking.js +8 -1
  25. package/dist/recorder/recorder.js +5 -2
  26. package/dist/runner/options.d.ts +6 -0
  27. package/dist/runner/pooled-run.js +8 -7
  28. package/dist/runner/registry.d.ts +13 -4
  29. package/dist/runner/registry.js +26 -9
  30. package/dist/runner/reporter.d.ts +6 -4
  31. package/dist/runner/reporter.js +7 -1
  32. package/dist/runner/retries-option.d.ts +10 -0
  33. package/dist/runner/retries-option.js +12 -0
  34. package/dist/runner/retry.d.ts +13 -0
  35. package/dist/runner/retry.js +43 -0
  36. package/dist/runner/run-test.d.ts +2 -0
  37. package/dist/runner/run-test.js +8 -2
  38. package/dist/runner/run.d.ts +3 -0
  39. package/dist/runner/run.js +3 -1
  40. package/dist/runner/skip.d.ts +2 -1
  41. package/dist/runner/skip.js +3 -2
  42. package/dist/runner/suite.d.ts +10 -4
  43. package/dist/runner/suite.js +19 -10
  44. package/dist/runner/test-runner.js +15 -6
  45. package/dist/runner/worker-pool.d.ts +1 -0
  46. package/dist/runner/worker-pool.js +1 -0
  47. package/dist/runner/worker-protocol.d.ts +3 -0
  48. package/dist/runner/worker-session.js +6 -3
  49. package/dist/runner/worker.js +3 -0
  50. package/dist/steps/model.d.ts +5 -0
  51. package/dist/steps/parse.js +59 -24
  52. package/dist/types/config.d.ts +7 -0
  53. package/dist/types/test.d.ts +22 -2
  54. package/package.json +2 -1
package/dist/api.d.ts CHANGED
@@ -2,5 +2,5 @@ export { test, describe, beforeEach, afterEach, beforeAll, afterAll, } from "./r
2
2
  export { expect } from "./assert/expect.js";
3
3
  export { defineConfig } from "./config/config.js";
4
4
  export { defineStep, type StepDefinition, type StepResult, type StepRunInput, } from "./flows/steps/index.js";
5
- export type { AllHookFixtures, TestFixtures } from "./types/test.js";
5
+ export type { AllHookFixtures, TestDetails, TestFixtures, } from "./types/test.js";
6
6
  export { generatedFile, type GeneratedFile, type FileInput, } from "./browser/generated-files.js";
@@ -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 }),
@@ -59,7 +60,7 @@ export async function main(argv, io) {
59
60
  io.out(`${JSON.stringify(tests, null, 2)}\n`);
60
61
  else
61
62
  for (const found of tests)
62
- io.out(`${found.name}${found.modifier ? ` [${found.modifier}]` : ""}\n ${found.file}\n`);
63
+ io.out(`${found.name}${found.modifier ? ` [${found.modifier}${found.reason ? `: ${found.reason}` : ""}]` : ""}\n ${found.file}\n`);
63
64
  return 0;
64
65
  }
65
66
  const human = values.json
@@ -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;