@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.
- package/dist/cli/args.d.ts +4 -1
- package/dist/cli/args.js +7 -2
- package/dist/cli/console-reporter.js +11 -2
- package/dist/cli/flow.js +5 -0
- package/dist/cli/main.js +1 -0
- package/dist/cli/usage.d.ts +1 -1
- package/dist/cli/usage.js +2 -0
- package/dist/config/config.js +3 -0
- package/dist/config/retries.d.ts +8 -0
- package/dist/config/retries.js +19 -0
- package/dist/flows/run/node-pool.d.ts +9 -0
- package/dist/flows/run/node-pool.js +27 -0
- package/dist/flows/run/project-options.d.ts +44 -0
- package/dist/flows/run/project-options.js +8 -0
- package/dist/flows/run/project.d.ts +4 -40
- package/dist/flows/run/project.js +12 -33
- package/dist/recorder/candidates.d.ts +2 -1
- package/dist/recorder/candidates.js +44 -1
- package/dist/recorder/capture.d.ts +4 -2
- package/dist/recorder/capture.js +12 -4
- package/dist/recorder/page-messages.d.ts +2 -0
- package/dist/recorder/ranking.d.ts +2 -0
- package/dist/recorder/ranking.js +8 -1
- package/dist/recorder/recorder.js +5 -2
- package/dist/runner/options.d.ts +6 -0
- package/dist/runner/pooled-run.js +5 -4
- package/dist/runner/reporter.d.ts +6 -4
- package/dist/runner/reporter.js +7 -1
- package/dist/runner/retries-option.d.ts +10 -0
- package/dist/runner/retries-option.js +12 -0
- package/dist/runner/retry.d.ts +13 -0
- package/dist/runner/retry.js +43 -0
- package/dist/runner/run-test.d.ts +2 -0
- package/dist/runner/run-test.js +8 -2
- package/dist/runner/run.d.ts +1 -0
- package/dist/runner/run.js +1 -0
- package/dist/runner/test-runner.js +12 -3
- package/dist/runner/worker-pool.d.ts +1 -0
- package/dist/runner/worker-pool.js +1 -0
- package/dist/runner/worker-protocol.d.ts +3 -0
- package/dist/runner/worker-session.js +6 -3
- package/dist/runner/worker.js +3 -0
- package/dist/steps/model.d.ts +3 -0
- package/dist/steps/parse.js +30 -21
- package/dist/types/config.d.ts +7 -0
- package/dist/types/test.d.ts +12 -2
- package/package.json +2 -1
package/dist/cli/args.d.ts
CHANGED
|
@@ -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 }),
|
package/dist/cli/usage.d.ts
CHANGED
|
@@ -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)
|
package/dist/config/config.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
10
|
-
export
|
|
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
|
-
|
|
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),
|
|
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),
|
|
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;
|
package/dist/recorder/capture.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/recorder/ranking.js
CHANGED
|
@@ -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 && {
|
|
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
|
-
|
|
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
|
}
|
package/dist/runner/options.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/runner/reporter.js
CHANGED
|
@@ -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
|
}
|
package/dist/runner/run-test.js
CHANGED
|
@@ -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,
|
package/dist/runner/run.d.ts
CHANGED
|
@@ -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";
|
package/dist/runner/run.js
CHANGED
|
@@ -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,
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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())
|
package/dist/runner/worker.js
CHANGED
|
@@ -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;
|
package/dist/steps/model.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/steps/parse.js
CHANGED
|
@@ -44,32 +44,40 @@ function callbackBody(node) {
|
|
|
44
44
|
? node.body
|
|
45
45
|
: undefined;
|
|
46
46
|
}
|
|
47
|
-
/** `
|
|
48
|
-
function
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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.
|
package/dist/types/test.d.ts
CHANGED
|
@@ -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.
|
|
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",
|