@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.
- package/dist/api.d.ts +1 -1
- 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 +2 -1
- 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 +8 -7
- package/dist/runner/registry.d.ts +13 -4
- package/dist/runner/registry.js +26 -9
- 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 +3 -0
- package/dist/runner/run.js +3 -1
- package/dist/runner/skip.d.ts +2 -1
- package/dist/runner/skip.js +3 -2
- package/dist/runner/suite.d.ts +10 -4
- package/dist/runner/suite.js +19 -10
- package/dist/runner/test-runner.js +15 -6
- 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 +5 -0
- package/dist/steps/parse.js +59 -24
- package/dist/types/config.d.ts +7 -0
- package/dist/types/test.d.ts +22 -2
- package/package.json +2 -1
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) */
|
|
@@ -70,18 +70,19 @@ export function poolMode(input) {
|
|
|
70
70
|
export async function runPooled(pool, planned, items, reporter, signal) {
|
|
71
71
|
reporter.setOrder(planned);
|
|
72
72
|
for (const { test, browser } of planned) {
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
reporter.onTestSkipped(test, browser,
|
|
73
|
+
const skip = staticSkip(test);
|
|
74
|
+
if (skip)
|
|
75
|
+
reporter.onTestSkipped(test, browser, skip);
|
|
76
76
|
}
|
|
77
77
|
const started = new Set();
|
|
78
78
|
const ended = new Set();
|
|
79
|
-
const begin = (index) => {
|
|
80
|
-
|
|
79
|
+
const begin = (index, retry) => {
|
|
80
|
+
// A test run again starts again; the first start is reported once however it arrives
|
|
81
|
+
if (started.has(index) && !retry)
|
|
81
82
|
return;
|
|
82
83
|
started.add(index);
|
|
83
84
|
const { test, browser } = planned[index];
|
|
84
|
-
reporter.onTestStart(test, browser);
|
|
85
|
+
reporter.onTestStart(test, browser, retry);
|
|
85
86
|
};
|
|
86
87
|
const end = (index, result) => {
|
|
87
88
|
begin(index);
|
|
@@ -92,7 +93,7 @@ export async function runPooled(pool, planned, items, reporter, signal) {
|
|
|
92
93
|
const results = await pool.run(items, {
|
|
93
94
|
...(signal && { signal }),
|
|
94
95
|
onEvent: (event) => event.type === "test-start"
|
|
95
|
-
? begin(event.item.index)
|
|
96
|
+
? begin(event.item.index, event.retry)
|
|
96
97
|
: end(event.item.index, event.result),
|
|
97
98
|
});
|
|
98
99
|
results.forEach((result, position) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase } from "../types/test.js";
|
|
1
|
+
import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase, TestDetails } from "../types/test.js";
|
|
2
2
|
type Body = TestCaseBase["function"];
|
|
3
3
|
/** `test(title, fn)`, `test.skip(title, fn)`, `test.skip(condition, reason)` and the other forms */
|
|
4
4
|
export interface TestFunction {
|
|
@@ -10,16 +10,25 @@ export interface TestFunction {
|
|
|
10
10
|
/** Runs only this test (and the other `only` ones) */
|
|
11
11
|
only(title: string, fn: Body): void;
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* With a title and a body it registers a skipped test, `{ reason }` saying why; with a condition it skips the
|
|
15
|
+
* running test when the condition is true
|
|
16
|
+
*/
|
|
14
17
|
interface SkipFunction {
|
|
15
18
|
(title: string, fn: Body): void;
|
|
19
|
+
(title: string, details: TestDetails, fn: Body): void;
|
|
16
20
|
(condition: boolean, reason?: string): void;
|
|
17
21
|
}
|
|
22
|
+
/** `describe.skip` and `describe.fixme`: every test inside gets the modifier, and `{ reason }` says why */
|
|
23
|
+
interface DescribeSkipFunction {
|
|
24
|
+
(title: string, fn: () => void): void;
|
|
25
|
+
(title: string, details: TestDetails, fn: () => void): void;
|
|
26
|
+
}
|
|
18
27
|
/** `describe(title, fn)` and the same with a modifier on every test inside */
|
|
19
28
|
export interface DescribeFunction {
|
|
20
29
|
(title: string, fn: () => void): void;
|
|
21
|
-
skip
|
|
22
|
-
fixme
|
|
30
|
+
skip: DescribeSkipFunction;
|
|
31
|
+
fixme: DescribeSkipFunction;
|
|
23
32
|
only(title: string, fn: () => void): void;
|
|
24
33
|
}
|
|
25
34
|
export declare const test: TestFunction;
|
package/dist/runner/registry.js
CHANGED
|
@@ -4,8 +4,8 @@ import { SkipSignal } from "./skip.js";
|
|
|
4
4
|
import { addHook, currentSuite, enterSuite, leaveSuite, modifierOf, resetSuites, } from "./suite.js";
|
|
5
5
|
const registered = [];
|
|
6
6
|
/** Runs `fn` as the `describe` block `title`, under `modifier` when there is one. `fn` runs immediately and must be synchronous. */
|
|
7
|
-
function openDescribe(file, title, fn, modifier) {
|
|
8
|
-
enterSuite(file, title, modifier);
|
|
7
|
+
function openDescribe(file, title, fn, modifier, reason) {
|
|
8
|
+
enterSuite(file, title, modifier, reason);
|
|
9
9
|
try {
|
|
10
10
|
const result = fn();
|
|
11
11
|
if (result instanceof Promise) {
|
|
@@ -17,18 +17,17 @@ function openDescribe(file, title, fn, modifier) {
|
|
|
17
17
|
leaveSuite();
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
-
function add(file, title, fn, modifier) {
|
|
20
|
+
function add(file, title, fn, modifier, reason) {
|
|
21
21
|
const suite = currentSuite(file);
|
|
22
22
|
const titlePath = [...titlesOf(suite), title];
|
|
23
23
|
const name = titlePath.join(" > ");
|
|
24
|
-
const effective = modifierOf(modifier, suite);
|
|
25
24
|
registered.push({
|
|
26
25
|
name,
|
|
27
26
|
titlePath,
|
|
28
27
|
function: fn,
|
|
29
28
|
file,
|
|
30
29
|
suite,
|
|
31
|
-
...(
|
|
30
|
+
...modifierOf({ modifier, ...(reason !== undefined && { reason }) }, suite),
|
|
32
31
|
});
|
|
33
32
|
logger.debug("Registered test %s", name);
|
|
34
33
|
}
|
|
@@ -40,26 +39,44 @@ function titlesOf(suite) {
|
|
|
40
39
|
titles.unshift(s.title);
|
|
41
40
|
return titles;
|
|
42
41
|
}
|
|
42
|
+
/** The body and `{ reason }` of `(title, fn)` and `(title, details, fn)` */
|
|
43
|
+
function bodyAndReason(call, title, second, third) {
|
|
44
|
+
const [details, fn] = typeof second === "function"
|
|
45
|
+
? [undefined, second]
|
|
46
|
+
: [second, third];
|
|
47
|
+
if (typeof fn !== "function")
|
|
48
|
+
throw new TypeError(`${call}(${JSON.stringify(title)}) needs a function as its last argument`);
|
|
49
|
+
const reason = details?.reason;
|
|
50
|
+
return { fn: fn, ...(reason !== undefined && { reason }) };
|
|
51
|
+
}
|
|
43
52
|
/** `test.skip` and `test.fixme`: a title and a body register a test with the modifier; a condition skips the running test */
|
|
44
53
|
function skipping(annotation) {
|
|
45
|
-
return ((first, second) => {
|
|
54
|
+
return ((first, second, third) => {
|
|
46
55
|
if (typeof first === "string") {
|
|
56
|
+
const { fn, reason } = bodyAndReason(`test.${annotation}`, first, second, third);
|
|
47
57
|
// Frames: Error, callerFile, this function, the spec
|
|
48
|
-
add(callerFile(), first,
|
|
58
|
+
add(callerFile(), first, fn, annotation, reason);
|
|
49
59
|
return;
|
|
50
60
|
}
|
|
51
61
|
if (first)
|
|
52
62
|
throw new SkipSignal(annotation, second);
|
|
53
63
|
});
|
|
54
64
|
}
|
|
65
|
+
/** `describe.skip` and `describe.fixme`, with or without `{ reason }` */
|
|
66
|
+
function describeSkipping(annotation) {
|
|
67
|
+
return ((title, second, third) => {
|
|
68
|
+
const { fn, reason } = bodyAndReason(`describe.${annotation}`, title, second, third);
|
|
69
|
+
openDescribe(callerFile(), title, fn, annotation, reason);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
55
72
|
export const test = Object.assign((title, fn) => add(callerFile(), title, fn), {
|
|
56
73
|
skip: skipping("skip"),
|
|
57
74
|
fixme: skipping("fixme"),
|
|
58
75
|
only: (title, fn) => add(callerFile(), title, fn, "only"),
|
|
59
76
|
});
|
|
60
77
|
export const describe = Object.assign((title, fn) => openDescribe(callerFile(), title, fn), {
|
|
61
|
-
skip: (
|
|
62
|
-
fixme: (
|
|
78
|
+
skip: describeSkipping("skip"),
|
|
79
|
+
fixme: describeSkipping("fixme"),
|
|
63
80
|
only: (title, fn) => openDescribe(callerFile(), title, fn, "only"),
|
|
64
81
|
});
|
|
65
82
|
/** Runs before each test of the `describe` block it is written in (of the file, at the top level) */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestSummary } from "../types/test.js";
|
|
1
|
+
import type { RegisteredTestCase, TestError, TestLogEntry, TestResult, TestRetries, TestSummary } from "../types/test.js";
|
|
2
2
|
import type { SupportedBrowser } from "../types/browser.js";
|
|
3
3
|
/** What a run reports while it happens */
|
|
4
4
|
export type RunEvent = {
|
|
@@ -14,6 +14,8 @@ export type RunEvent = {
|
|
|
14
14
|
name: string;
|
|
15
15
|
file: string;
|
|
16
16
|
browser: SupportedBrowser;
|
|
17
|
+
/** The attempt, counting from 1, when the test is run again after a failure; absent for the first */
|
|
18
|
+
retry?: number;
|
|
17
19
|
} | {
|
|
18
20
|
type: "test-end";
|
|
19
21
|
name: string;
|
|
@@ -36,7 +38,7 @@ export interface ReporterOptions {
|
|
|
36
38
|
}
|
|
37
39
|
/** What `runOneTest` reports a test to: the reporter of a run, or a worker that relays it */
|
|
38
40
|
export interface TestSink {
|
|
39
|
-
onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
|
|
41
|
+
onTestStart(test: RegisteredTestCase, browser: SupportedBrowser, retry?: number): void;
|
|
40
42
|
onTestEnd(test: RegisteredTestCase, browser: SupportedBrowser, error?: TestError, extras?: TestEndExtras): void;
|
|
41
43
|
/** A test that does not run: reported as started and ended at once */
|
|
42
44
|
onTestSkipped(test: RegisteredTestCase, browser: SupportedBrowser, skip: TestSkip): void;
|
|
@@ -47,7 +49,7 @@ export interface TestSkip {
|
|
|
47
49
|
reason?: string | undefined;
|
|
48
50
|
}
|
|
49
51
|
/** What a test's report entry lists besides its status */
|
|
50
|
-
export interface TestEndExtras {
|
|
52
|
+
export interface TestEndExtras extends TestRetries {
|
|
51
53
|
logs?: TestLogEntry[];
|
|
52
54
|
logsDropped?: number;
|
|
53
55
|
screenshots?: {
|
|
@@ -73,7 +75,7 @@ export default class TestReporter implements TestSink {
|
|
|
73
75
|
}[]): void;
|
|
74
76
|
onStart(total: number, workers?: number): void;
|
|
75
77
|
onEnd(): Promise<TestSummary>;
|
|
76
|
-
onTestStart(test: RegisteredTestCase, browser: SupportedBrowser): void;
|
|
78
|
+
onTestStart(test: RegisteredTestCase, browser: SupportedBrowser, retry?: number): void;
|
|
77
79
|
onTestSkipped(test: RegisteredTestCase, browser: SupportedBrowser, { annotation, reason }: TestSkip): void;
|
|
78
80
|
/**
|
|
79
81
|
* Ends a test that ran in a worker: the worker built the result (masked, with its own duration), this keeps the
|
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, 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
|
@@ -21,6 +21,8 @@ export interface FoundTest {
|
|
|
21
21
|
file: string;
|
|
22
22
|
/** `skip` or `fixme` (not run), or `only` (focused); absent for a plain test. A modifier on a `describe` counts. */
|
|
23
23
|
modifier?: Modifier;
|
|
24
|
+
/** The `{ reason }` given with the `skip` or `fixme` that applies */
|
|
25
|
+
reason?: string;
|
|
24
26
|
}
|
|
25
27
|
/** Imports the project's specs without running anything and lists the tests the options select */
|
|
26
28
|
export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
|
|
@@ -28,6 +30,7 @@ export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[
|
|
|
28
30
|
export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
|
|
29
31
|
export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
|
|
30
32
|
export { parseWorkers } from "../config/workers.js";
|
|
33
|
+
export { parseRetries } from "../config/retries.js";
|
|
31
34
|
export type { RunEvent } from "./reporter.js";
|
|
32
35
|
export type { Modifier, TestError, TestResult, TestSummary, } from "../types/test.js";
|
|
33
36
|
export type { ResolvedGroup } from "./groups.js";
|
package/dist/runner/run.js
CHANGED
|
@@ -35,10 +35,11 @@ export async function runTests(options = {}) {
|
|
|
35
35
|
}
|
|
36
36
|
/** Imports the project's specs without running anything and lists the tests the options select */
|
|
37
37
|
export async function listTests(options = {}) {
|
|
38
|
-
return inProject(options, async (runner) => (await runner.register()).map(({ name, file, modifier }) => ({
|
|
38
|
+
return inProject(options, async (runner) => (await runner.register()).map(({ name, file, modifier, reason }) => ({
|
|
39
39
|
name,
|
|
40
40
|
file,
|
|
41
41
|
...(modifier && { modifier }),
|
|
42
|
+
...(reason !== undefined && { reason }),
|
|
42
43
|
})));
|
|
43
44
|
}
|
|
44
45
|
/** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
|
|
@@ -47,4 +48,5 @@ export async function listGroups(options = {}) {
|
|
|
47
48
|
}
|
|
48
49
|
export { BROWSER_NAMES, checkBrowserList } from "../config/browsers.js";
|
|
49
50
|
export { parseWorkers } from "../config/workers.js";
|
|
51
|
+
export { parseRetries } from "../config/retries.js";
|
|
50
52
|
export { DEFAULT_PORT } from "./ports.js";
|
package/dist/runner/skip.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RegisteredTestCase } from "../types/test.js";
|
|
2
|
+
import type { TestSkip } from "./reporter.js";
|
|
2
3
|
/** Thrown by `test.skip(condition, reason)` and `test.fixme(condition, reason)` inside a test: the test ends as skipped */
|
|
3
4
|
export declare class SkipSignal extends Error {
|
|
4
5
|
readonly annotation: "skip" | "fixme";
|
|
@@ -7,4 +8,4 @@ export declare class SkipSignal extends Error {
|
|
|
7
8
|
}
|
|
8
9
|
export declare function isSkipSignal(error: unknown): error is SkipSignal;
|
|
9
10
|
/** Why a test does not run at all: it is marked `skip` or `fixme`, itself or by a `describe` around it */
|
|
10
|
-
export declare function staticSkip(test: Pick<RegisteredTestCase, "modifier"
|
|
11
|
+
export declare function staticSkip(test: Pick<RegisteredTestCase, "modifier" | "reason">): TestSkip | undefined;
|
package/dist/runner/skip.js
CHANGED
|
@@ -12,7 +12,8 @@ export function isSkipSignal(error) {
|
|
|
12
12
|
}
|
|
13
13
|
/** Why a test does not run at all: it is marked `skip` or `fixme`, itself or by a `describe` around it */
|
|
14
14
|
export function staticSkip(test) {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const { modifier, reason } = test;
|
|
16
|
+
return modifier === "skip" || modifier === "fixme"
|
|
17
|
+
? { annotation: modifier, ...(reason !== undefined && { reason }) }
|
|
17
18
|
: undefined;
|
|
18
19
|
}
|
package/dist/runner/suite.d.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import type { EachHook, Modifier, Suite } from "../types/test.js";
|
|
2
2
|
/** The four hook lists a suite can hold */
|
|
3
3
|
export type HookKind = "beforeEach" | "afterEach" | "beforeAll" | "afterAll";
|
|
4
|
-
export declare function newSuite(title?: string, parent?: Suite, modifier?: Modifier): Suite;
|
|
4
|
+
export declare function newSuite(title?: string, parent?: Suite, modifier?: Modifier, reason?: string): Suite;
|
|
5
5
|
/** The suite a test or hook written now belongs to: the innermost open `describe`, else the file's own */
|
|
6
6
|
export declare function currentSuite(file: string): Suite;
|
|
7
7
|
/** Opens a `describe` block under the current suite; `leaveSuite` closes it */
|
|
8
|
-
export declare function enterSuite(file: string, title: string, modifier?: Modifier): Suite;
|
|
8
|
+
export declare function enterSuite(file: string, title: string, modifier?: Modifier, reason?: string): Suite;
|
|
9
9
|
export declare function leaveSuite(): void;
|
|
10
10
|
/** Forgets every suite: a run registers its specs afresh */
|
|
11
11
|
export declare function resetSuites(): void;
|
|
12
12
|
/**
|
|
13
13
|
* What applies to a test: the innermost `skip` or `fixme` (the test's own, then its suites', inside out), else
|
|
14
|
-
* `only` when the test or any suite around it is focused. A skip beats a focus.
|
|
14
|
+
* `only` when the test or any suite around it is focused. A skip beats a focus. The reason comes with the skip
|
|
15
|
+
* or fixme that applies.
|
|
15
16
|
*/
|
|
16
|
-
export declare function modifierOf(own:
|
|
17
|
+
export declare function modifierOf(own: Mark, suite: Suite): Mark;
|
|
18
|
+
/** A modifier and the reason given with it */
|
|
19
|
+
export interface Mark {
|
|
20
|
+
modifier?: Modifier;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
17
23
|
/** The suites from the file's root down to `suite` */
|
|
18
24
|
export declare function suiteChain(suite: Suite): Suite[];
|
|
19
25
|
/** The `beforeEach` hooks of a test: outermost suite first, in the order they were written */
|
package/dist/runner/suite.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
const roots = new Map();
|
|
3
3
|
/** The `describe` blocks being registered, outermost first */
|
|
4
4
|
const open = [];
|
|
5
|
-
export function newSuite(title, parent, modifier) {
|
|
5
|
+
export function newSuite(title, parent, modifier, reason) {
|
|
6
6
|
return {
|
|
7
7
|
...(title !== undefined && { title }),
|
|
8
8
|
...(parent && { parent }),
|
|
9
9
|
...(modifier && { modifier }),
|
|
10
|
+
...(reason !== undefined && { reason }),
|
|
10
11
|
};
|
|
11
12
|
}
|
|
12
13
|
/** The suite a test or hook written now belongs to: the innermost open `describe`, else the file's own */
|
|
@@ -22,8 +23,8 @@ export function currentSuite(file) {
|
|
|
22
23
|
return root;
|
|
23
24
|
}
|
|
24
25
|
/** Opens a `describe` block under the current suite; `leaveSuite` closes it */
|
|
25
|
-
export function enterSuite(file, title, modifier) {
|
|
26
|
-
const suite = newSuite(title, currentSuite(file), modifier);
|
|
26
|
+
export function enterSuite(file, title, modifier, reason) {
|
|
27
|
+
const suite = newSuite(title, currentSuite(file), modifier, reason);
|
|
27
28
|
open.push(suite);
|
|
28
29
|
return suite;
|
|
29
30
|
}
|
|
@@ -37,18 +38,26 @@ export function resetSuites() {
|
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
39
40
|
* What applies to a test: the innermost `skip` or `fixme` (the test's own, then its suites', inside out), else
|
|
40
|
-
* `only` when the test or any suite around it is focused. A skip beats a focus.
|
|
41
|
+
* `only` when the test or any suite around it is focused. A skip beats a focus. The reason comes with the skip
|
|
42
|
+
* or fixme that applies.
|
|
41
43
|
*/
|
|
42
44
|
export function modifierOf(own, suite) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
if (own.modifier === "skip" || own.modifier === "fixme")
|
|
46
|
+
return markOf(own);
|
|
47
|
+
let focused = own.modifier === "only";
|
|
48
|
+
for (let s = suite; s; s = s.parent) {
|
|
46
49
|
if (s.modifier === "only")
|
|
47
50
|
focused = true;
|
|
48
|
-
else
|
|
49
|
-
|
|
51
|
+
else if (s.modifier)
|
|
52
|
+
return markOf(s);
|
|
50
53
|
}
|
|
51
|
-
return
|
|
54
|
+
return focused ? { modifier: "only" } : {};
|
|
55
|
+
}
|
|
56
|
+
function markOf({ modifier, reason }) {
|
|
57
|
+
return {
|
|
58
|
+
...(modifier && { modifier }),
|
|
59
|
+
...(reason !== undefined && { reason }),
|
|
60
|
+
};
|
|
52
61
|
}
|
|
53
62
|
/** The suites from the file's root down to `suite` */
|
|
54
63
|
export function suiteChain(suite) {
|
|
@@ -14,6 +14,8 @@ import { concurrency, planItems, poolMode, runPooled, serialKeyOf, } from "./poo
|
|
|
14
14
|
import { allHookFixtures, runOneTest } from "./run-test.js";
|
|
15
15
|
import { staticSkip } from "./skip.js";
|
|
16
16
|
import { browsersOfRun, loadSpecs, selectTests } from "./select.js";
|
|
17
|
+
import { resolveRetries } from "./retries-option.js";
|
|
18
|
+
import { runWithRetries } from "./retry.js";
|
|
17
19
|
import { resolveWorkers } from "./workers-option.js";
|
|
18
20
|
import { WorkerPool } from "./worker-pool.js";
|
|
19
21
|
import { processWorkerFactory } from "./worker-process.js";
|
|
@@ -75,6 +77,12 @@ export default class TestRunner {
|
|
|
75
77
|
env: process.env.SAMURAI_WORKERS,
|
|
76
78
|
config: config.workers,
|
|
77
79
|
});
|
|
80
|
+
// A bad `retries` is an error here too, before any browser starts
|
|
81
|
+
const retries = resolveRetries({
|
|
82
|
+
flag: this.options.retries,
|
|
83
|
+
env: process.env.SAMURAI_RETRIES,
|
|
84
|
+
config: config.retries,
|
|
85
|
+
});
|
|
78
86
|
const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir));
|
|
79
87
|
// A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
|
|
80
88
|
const lanes = concurrency(plan.items);
|
|
@@ -97,7 +105,7 @@ export default class TestRunner {
|
|
|
97
105
|
if (mode === "pool") {
|
|
98
106
|
const workers = Math.min(requested, lanes);
|
|
99
107
|
reporter.onStart(plan.planned.length, workers);
|
|
100
|
-
const pool = this.createPool(workers);
|
|
108
|
+
const pool = this.createPool(workers, retries);
|
|
101
109
|
try {
|
|
102
110
|
await runPooled(pool, plan.planned, plan.items, reporter, signal);
|
|
103
111
|
}
|
|
@@ -115,9 +123,9 @@ export default class TestRunner {
|
|
|
115
123
|
try {
|
|
116
124
|
for (const browser of browsers) {
|
|
117
125
|
for (const test of selected) {
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
reporter.onTestSkipped(test, browser,
|
|
126
|
+
const skip = staticSkip(test);
|
|
127
|
+
if (skip) {
|
|
128
|
+
reporter.onTestSkipped(test, browser, skip);
|
|
121
129
|
continue;
|
|
122
130
|
}
|
|
123
131
|
if (signal?.aborted) {
|
|
@@ -128,7 +136,7 @@ export default class TestRunner {
|
|
|
128
136
|
});
|
|
129
137
|
continue;
|
|
130
138
|
}
|
|
131
|
-
await runOneTest(test, browser, this.run, this.options, loaded.srcDir,
|
|
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);
|