@itmetsam/samurai-framework 0.12.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -29
- package/dist/api.d.ts +2 -2
- package/dist/api.js +1 -1
- package/dist/cli/args.d.ts +4 -1
- package/dist/cli/args.js +7 -2
- package/dist/cli/console-reporter.js +17 -4
- 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 +6 -0
- package/dist/config/forbid-only.d.ts +5 -0
- package/dist/config/forbid-only.js +9 -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 +30 -38
- package/dist/logger/index.d.ts +6 -0
- package/dist/logger/index.js +47 -6
- 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/all-hooks.d.ts +26 -0
- package/dist/runner/all-hooks.js +61 -0
- package/dist/runner/focus.d.ts +5 -0
- package/dist/runner/focus.js +19 -0
- package/dist/runner/hooks.d.ts +24 -0
- package/dist/runner/hooks.js +79 -0
- package/dist/runner/options.d.ts +6 -0
- package/dist/runner/pooled-run.d.ts +7 -1
- package/dist/runner/pooled-run.js +43 -14
- package/dist/runner/project.js +5 -0
- package/dist/runner/registry.d.ts +35 -4
- package/dist/runner/registry.js +66 -11
- package/dist/runner/reporter.d.ts +14 -4
- package/dist/runner/reporter.js +29 -2
- 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 +8 -1
- package/dist/runner/run-test.js +34 -2
- package/dist/runner/run.d.ts +5 -2
- package/dist/runner/run.js +6 -1
- package/dist/runner/skip.d.ts +10 -0
- package/dist/runner/skip.js +18 -0
- package/dist/runner/suite.d.ts +24 -0
- package/dist/runner/suite.js +73 -0
- package/dist/runner/test-error.js +4 -0
- package/dist/runner/test-runner.js +54 -20
- package/dist/runner/worker-pool.d.ts +9 -1
- package/dist/runner/worker-pool.js +35 -2
- package/dist/runner/worker-protocol.d.ts +3 -0
- package/dist/runner/worker-session.js +17 -4
- package/dist/runner/worker.js +3 -0
- package/dist/steps/model.d.ts +3 -0
- package/dist/steps/parse.js +32 -7
- package/dist/types/config.d.ts +15 -1
- package/dist/types/test.d.ts +55 -3
- package/package.json +5 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Thrown by `test.skip(condition, reason)` and `test.fixme(condition, reason)` inside a test: the test ends as skipped */
|
|
2
|
+
export class SkipSignal extends Error {
|
|
3
|
+
constructor(annotation, reason) {
|
|
4
|
+
super(reason ?? "skipped");
|
|
5
|
+
this.annotation = annotation;
|
|
6
|
+
this.reason = reason;
|
|
7
|
+
this.name = "SkipSignal";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function isSkipSignal(error) {
|
|
11
|
+
return error instanceof SkipSignal;
|
|
12
|
+
}
|
|
13
|
+
/** Why a test does not run at all: it is marked `skip` or `fixme`, itself or by a `describe` around it */
|
|
14
|
+
export function staticSkip(test) {
|
|
15
|
+
return test.modifier === "skip" || test.modifier === "fixme"
|
|
16
|
+
? test.modifier
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EachHook, Modifier, Suite } from "../types/test.js";
|
|
2
|
+
/** The four hook lists a suite can hold */
|
|
3
|
+
export type HookKind = "beforeEach" | "afterEach" | "beforeAll" | "afterAll";
|
|
4
|
+
export declare function newSuite(title?: string, parent?: Suite, modifier?: Modifier): Suite;
|
|
5
|
+
/** The suite a test or hook written now belongs to: the innermost open `describe`, else the file's own */
|
|
6
|
+
export declare function currentSuite(file: string): Suite;
|
|
7
|
+
/** Opens a `describe` block under the current suite; `leaveSuite` closes it */
|
|
8
|
+
export declare function enterSuite(file: string, title: string, modifier?: Modifier): Suite;
|
|
9
|
+
export declare function leaveSuite(): void;
|
|
10
|
+
/** Forgets every suite: a run registers its specs afresh */
|
|
11
|
+
export declare function resetSuites(): void;
|
|
12
|
+
/**
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export declare function modifierOf(own: Modifier | undefined, suite: Suite): Modifier | undefined;
|
|
17
|
+
/** The suites from the file's root down to `suite` */
|
|
18
|
+
export declare function suiteChain(suite: Suite): Suite[];
|
|
19
|
+
/** The `beforeEach` hooks of a test: outermost suite first, in the order they were written */
|
|
20
|
+
export declare function beforeEachOf(suite: Suite): EachHook[];
|
|
21
|
+
/** The `afterEach` hooks of a test: innermost suite first, in the order they were written */
|
|
22
|
+
export declare function afterEachOf(suite: Suite): EachHook[];
|
|
23
|
+
/** Adds a hook to the suite being registered */
|
|
24
|
+
export declare function addHook<K extends HookKind>(suite: Suite, kind: K, hook: NonNullable<Suite[K]>[number]): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** One root suite per spec file; a `describe` block is a child of the suite it is written in */
|
|
2
|
+
const roots = new Map();
|
|
3
|
+
/** The `describe` blocks being registered, outermost first */
|
|
4
|
+
const open = [];
|
|
5
|
+
export function newSuite(title, parent, modifier) {
|
|
6
|
+
return {
|
|
7
|
+
...(title !== undefined && { title }),
|
|
8
|
+
...(parent && { parent }),
|
|
9
|
+
...(modifier && { modifier }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** The suite a test or hook written now belongs to: the innermost open `describe`, else the file's own */
|
|
13
|
+
export function currentSuite(file) {
|
|
14
|
+
const inner = open[open.length - 1];
|
|
15
|
+
if (inner)
|
|
16
|
+
return inner;
|
|
17
|
+
let root = roots.get(file);
|
|
18
|
+
if (!root) {
|
|
19
|
+
root = newSuite();
|
|
20
|
+
roots.set(file, root);
|
|
21
|
+
}
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
/** 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);
|
|
27
|
+
open.push(suite);
|
|
28
|
+
return suite;
|
|
29
|
+
}
|
|
30
|
+
export function leaveSuite() {
|
|
31
|
+
open.pop();
|
|
32
|
+
}
|
|
33
|
+
/** Forgets every suite: a run registers its specs afresh */
|
|
34
|
+
export function resetSuites() {
|
|
35
|
+
roots.clear();
|
|
36
|
+
open.length = 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 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
|
+
*/
|
|
42
|
+
export function modifierOf(own, suite) {
|
|
43
|
+
let focused = own === "only";
|
|
44
|
+
let found = own === "only" ? undefined : own;
|
|
45
|
+
for (let s = suite; s && !found; s = s.parent) {
|
|
46
|
+
if (s.modifier === "only")
|
|
47
|
+
focused = true;
|
|
48
|
+
else
|
|
49
|
+
found = s.modifier;
|
|
50
|
+
}
|
|
51
|
+
return found ?? (focused ? "only" : undefined);
|
|
52
|
+
}
|
|
53
|
+
/** The suites from the file's root down to `suite` */
|
|
54
|
+
export function suiteChain(suite) {
|
|
55
|
+
const chain = [];
|
|
56
|
+
for (let s = suite; s; s = s.parent)
|
|
57
|
+
chain.unshift(s);
|
|
58
|
+
return chain;
|
|
59
|
+
}
|
|
60
|
+
/** The `beforeEach` hooks of a test: outermost suite first, in the order they were written */
|
|
61
|
+
export function beforeEachOf(suite) {
|
|
62
|
+
return suiteChain(suite).flatMap((s) => s.beforeEach ?? []);
|
|
63
|
+
}
|
|
64
|
+
/** The `afterEach` hooks of a test: innermost suite first, in the order they were written */
|
|
65
|
+
export function afterEachOf(suite) {
|
|
66
|
+
return suiteChain(suite)
|
|
67
|
+
.reverse()
|
|
68
|
+
.flatMap((s) => s.afterEach ?? []);
|
|
69
|
+
}
|
|
70
|
+
/** Adds a hook to the suite being registered */
|
|
71
|
+
export function addHook(suite, kind, hook) {
|
|
72
|
+
(suite[kind] ??= []).push(hook);
|
|
73
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { AssertionError, toReportValue } from "../assert/assertion-error.js";
|
|
2
|
+
import { HookError } from "./hooks.js";
|
|
2
3
|
/** Maps anything a test threw to the error stored in the report */
|
|
3
4
|
export function toTestError(err) {
|
|
5
|
+
// A hook's failure keeps what it threw (an assertion stays one) under the hook's own message
|
|
6
|
+
if (err instanceof HookError)
|
|
7
|
+
return { ...toTestError(err.cause), message: err.message };
|
|
4
8
|
if (err instanceof AssertionError) {
|
|
5
9
|
return {
|
|
6
10
|
message: err.message,
|
|
@@ -3,13 +3,19 @@ import path from "path";
|
|
|
3
3
|
import { ensureWarm } from "../browser/warm-up.js";
|
|
4
4
|
import { projectBrowser } from "../browsers/resolve.js";
|
|
5
5
|
import { loadConfig, projectDir, readConfig } from "../config/config.js";
|
|
6
|
+
import { forbidsOnly } from "../config/forbid-only.js";
|
|
6
7
|
import logger from "../logger/index.js";
|
|
8
|
+
import { AllHooks } from "./all-hooks.js";
|
|
9
|
+
import { assertNoOnly, narrowToOnly } from "./focus.js";
|
|
7
10
|
import { DEFAULT_SRC_DIR, DEFAULT_TEST_MATCH } from "./groups.js";
|
|
8
11
|
import { DEFAULT_PORT } from "./ports.js";
|
|
9
12
|
import TestReporter from "./reporter.js";
|
|
10
|
-
import { planItems, poolMode, runPooled, serialKeyOf, } from "./pooled-run.js";
|
|
11
|
-
import { runOneTest } from "./run-test.js";
|
|
13
|
+
import { concurrency, planItems, poolMode, runPooled, serialKeyOf, } from "./pooled-run.js";
|
|
14
|
+
import { allHookFixtures, runOneTest } from "./run-test.js";
|
|
15
|
+
import { staticSkip } from "./skip.js";
|
|
12
16
|
import { browsersOfRun, loadSpecs, selectTests } from "./select.js";
|
|
17
|
+
import { resolveRetries } from "./retries-option.js";
|
|
18
|
+
import { runWithRetries } from "./retry.js";
|
|
13
19
|
import { resolveWorkers } from "./workers-option.js";
|
|
14
20
|
import { WorkerPool } from "./worker-pool.js";
|
|
15
21
|
import { processWorkerFactory } from "./worker-process.js";
|
|
@@ -54,7 +60,10 @@ export default class TestRunner {
|
|
|
54
60
|
const { reportPath, onEvent, signal } = this.options;
|
|
55
61
|
// The specs are imported once: their top-level code runs once per run
|
|
56
62
|
const loaded = await loadSpecs(this.testFiles);
|
|
57
|
-
const
|
|
63
|
+
const config = await loadConfig();
|
|
64
|
+
// A focused test the config refuses stops the run before anything starts; else only the focused ones run
|
|
65
|
+
assertNoOnly(loaded.all, forbidsOnly(config), loaded.srcDir);
|
|
66
|
+
const selected = narrowToOnly(selectTests(loaded, this.options));
|
|
58
67
|
const browsers = await browsersOfRun(loaded.groups, this.options);
|
|
59
68
|
const reporter = new TestReporter({
|
|
60
69
|
environment: this.run.settings.environment,
|
|
@@ -63,32 +72,40 @@ export default class TestRunner {
|
|
|
63
72
|
...(onEvent && { onEvent }),
|
|
64
73
|
});
|
|
65
74
|
// How the run goes is settled first: a bad `workers` is an error before any browser starts
|
|
66
|
-
const config = await loadConfig();
|
|
67
75
|
const requested = resolveWorkers({
|
|
68
76
|
flag: this.options.workers,
|
|
69
77
|
env: process.env.SAMURAI_WORKERS,
|
|
70
78
|
config: config.workers,
|
|
71
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
|
+
});
|
|
72
86
|
const plan = planItems(selected, browsers, serialKeyOf(loaded.groups, config.groups, loaded.srcDir));
|
|
87
|
+
// A pool larger than what can overlap would only hold idle workers: a group that runs one at a time needs one
|
|
88
|
+
const lanes = concurrency(plan.items);
|
|
73
89
|
const mode = poolMode({
|
|
74
90
|
workers: requested,
|
|
75
|
-
items:
|
|
91
|
+
items: lanes,
|
|
76
92
|
inlineConfig: this.project.inlineConfig ?? false,
|
|
77
93
|
});
|
|
78
94
|
if (mode === "inline-config" && this.workersAsked(config.workers))
|
|
79
95
|
logger.warn("workers needs samurai.config.ts in the project folder; running on one worker");
|
|
80
96
|
// A missing browser is one clear error before the run, not a failure per test, and before any browser has run a
|
|
81
97
|
// test; a fresh build's slow first start happens here too, outside any test's timeout
|
|
82
|
-
|
|
98
|
+
// Skipped tests need no browser, so a run of only those needs none installed
|
|
99
|
+
if (selected.some((test) => !staticSkip(test))) {
|
|
83
100
|
for (const browser of browsers)
|
|
84
101
|
await projectBrowser(browser);
|
|
85
102
|
for (const browser of browsers)
|
|
86
103
|
await ensureWarm(browser, { port: this.options.port ?? DEFAULT_PORT }, signal);
|
|
87
104
|
}
|
|
88
105
|
if (mode === "pool") {
|
|
89
|
-
const workers = Math.min(requested,
|
|
90
|
-
reporter.onStart(plan.
|
|
91
|
-
const pool = this.createPool(workers);
|
|
106
|
+
const workers = Math.min(requested, lanes);
|
|
107
|
+
reporter.onStart(plan.planned.length, workers);
|
|
108
|
+
const pool = this.createPool(workers, retries);
|
|
92
109
|
try {
|
|
93
110
|
await runPooled(pool, plan.planned, plan.items, reporter, signal);
|
|
94
111
|
}
|
|
@@ -98,19 +115,35 @@ export default class TestRunner {
|
|
|
98
115
|
return reporter.onEnd();
|
|
99
116
|
}
|
|
100
117
|
reporter.onStart(selected.length * browsers.length);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
// The `afterAll` hooks run when the last test is done, before the run ends
|
|
119
|
+
const allHooks = new AllHooks({
|
|
120
|
+
timeout: this.run.settings.timeout,
|
|
121
|
+
fixtures: (browser) => allHookFixtures(this.run, browser),
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
for (const browser of browsers) {
|
|
125
|
+
for (const test of selected) {
|
|
126
|
+
const annotation = staticSkip(test);
|
|
127
|
+
if (annotation) {
|
|
128
|
+
reporter.onTestSkipped(test, browser, { annotation });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (signal?.aborted) {
|
|
132
|
+
reporter.onTestStart(test, browser);
|
|
133
|
+
reporter.onTestEnd(test, browser, {
|
|
134
|
+
message: "Run aborted",
|
|
135
|
+
type: "error",
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
await runWithRetries(test, browser, retries, signal, (sink, attempt) => runOneTest(test, browser, this.run, this.options, loaded.srcDir, sink, { allHooks, attempt }), reporter);
|
|
110
140
|
}
|
|
111
|
-
await runOneTest(test, browser, this.run, this.options, loaded.srcDir, reporter);
|
|
112
141
|
}
|
|
113
142
|
}
|
|
143
|
+
finally {
|
|
144
|
+
for (const error of await allHooks.closeAll())
|
|
145
|
+
logger.error("%s", error.message);
|
|
146
|
+
}
|
|
114
147
|
return reporter.onEnd();
|
|
115
148
|
}
|
|
116
149
|
/** Whether anything but the default says how many workers to use */
|
|
@@ -120,7 +153,7 @@ export default class TestRunner {
|
|
|
120
153
|
fromConfig !== undefined);
|
|
121
154
|
}
|
|
122
155
|
/** The pool of a run: up to `workers` worker processes that read the same project and settings as this run */
|
|
123
|
-
createPool(workers) {
|
|
156
|
+
createPool(workers, retries) {
|
|
124
157
|
const { settings } = this.run;
|
|
125
158
|
const { reportPath, headless, port } = this.options;
|
|
126
159
|
const options = {
|
|
@@ -138,6 +171,7 @@ export default class TestRunner {
|
|
|
138
171
|
...(typeof reportPath === "string" && { reportPath }),
|
|
139
172
|
files: this.testFiles,
|
|
140
173
|
basePort: port ?? DEFAULT_PORT,
|
|
174
|
+
...(retries > 0 && { retries }),
|
|
141
175
|
},
|
|
142
176
|
};
|
|
143
177
|
return (this.project.createPool ?? ((o) => new WorkerPool(o)))(options);
|
|
@@ -4,6 +4,7 @@ export type PoolEvent = {
|
|
|
4
4
|
type: "test-start";
|
|
5
5
|
item: WorkItem;
|
|
6
6
|
worker: number;
|
|
7
|
+
retry?: number;
|
|
7
8
|
} | {
|
|
8
9
|
type: "test-end";
|
|
9
10
|
item: WorkItem;
|
|
@@ -17,6 +18,8 @@ export interface PoolOptions {
|
|
|
17
18
|
factory: WorkerFactory;
|
|
18
19
|
/** The abort grace period in ms; 2000 by default */
|
|
19
20
|
graceMs?: number;
|
|
21
|
+
/** How long `dispose` waits for idle workers to leave on their own, in ms: their `afterAll` hooks run first. 60000 by default */
|
|
22
|
+
shutdownMs?: number;
|
|
20
23
|
}
|
|
21
24
|
export interface RunHooks {
|
|
22
25
|
onEvent?: (event: PoolEvent) => void;
|
|
@@ -40,8 +43,13 @@ export declare class WorkerPool {
|
|
|
40
43
|
constructor(options: PoolOptions);
|
|
41
44
|
/** Results in item order; may be called several times at once (flows): all calls share the cap */
|
|
42
45
|
run(items: WorkItem[], hooks?: RunHooks): Promise<TestResult[]>;
|
|
43
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Ends the pool; safe to call twice. A worker that has nothing to do is told to exit and given time to leave
|
|
48
|
+
* (it runs its `afterAll` hooks first); one that is busy is killed, as nobody waits for its test.
|
|
49
|
+
*/
|
|
44
50
|
dispose(): Promise<void>;
|
|
51
|
+
/** Asks the idle workers to exit and waits for them (up to `shutdownMs`), kills the busy ones and the stragglers */
|
|
52
|
+
private sendHome;
|
|
45
53
|
/** Hands queued items to ready workers, then starts workers for what is left */
|
|
46
54
|
private pump;
|
|
47
55
|
private busyKeys;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** How long a busy worker may take to wind down after an abort before it is killed */
|
|
2
2
|
const GRACE_MS = 2000;
|
|
3
|
+
const SHUTDOWN_MS = 60000;
|
|
3
4
|
/** Workers that die before they are ready this many times in a row fail the run, instead of being replaced for ever */
|
|
4
5
|
const STARTUP_ATTEMPTS = 3;
|
|
5
6
|
/**
|
|
@@ -49,14 +50,45 @@ export class WorkerPool {
|
|
|
49
50
|
this.pump();
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Ends the pool; safe to call twice. A worker that has nothing to do is told to exit and given time to leave
|
|
55
|
+
* (it runs its `afterAll` hooks first); one that is busy is killed, as nobody waits for its test.
|
|
56
|
+
*/
|
|
53
57
|
async dispose() {
|
|
54
58
|
if (this.disposed)
|
|
55
59
|
return;
|
|
56
60
|
this.disposed = true;
|
|
57
61
|
this.queue.length = 0;
|
|
58
|
-
this.
|
|
62
|
+
const leaving = this.sendHome();
|
|
59
63
|
this.rejectAll(new Error("WorkerPool.dispose(): the pool was disposed during a run"));
|
|
64
|
+
await leaving;
|
|
65
|
+
}
|
|
66
|
+
/** Asks the idle workers to exit and waits for them (up to `shutdownMs`), kills the busy ones and the stragglers */
|
|
67
|
+
async sendHome() {
|
|
68
|
+
for (const timer of this.timers)
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
this.timers.clear();
|
|
71
|
+
const slots = [...this.slots.values()];
|
|
72
|
+
this.slots.clear();
|
|
73
|
+
const gone = [];
|
|
74
|
+
for (const { handle, busy } of slots) {
|
|
75
|
+
if (busy) {
|
|
76
|
+
handle.kill();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
gone.push(new Promise((resolve) => handle.onExit(() => resolve())));
|
|
80
|
+
handle.send({ type: "exit" });
|
|
81
|
+
}
|
|
82
|
+
let timer;
|
|
83
|
+
await Promise.race([
|
|
84
|
+
Promise.all(gone),
|
|
85
|
+
new Promise((resolve) => {
|
|
86
|
+
timer = setTimeout(resolve, this.options.shutdownMs ?? SHUTDOWN_MS);
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
for (const { handle } of slots)
|
|
91
|
+
handle.kill();
|
|
60
92
|
}
|
|
61
93
|
/** Hands queued items to ready workers, then starts workers for what is left */
|
|
62
94
|
pump() {
|
|
@@ -155,6 +187,7 @@ export class WorkerPool {
|
|
|
155
187
|
type: "test-start",
|
|
156
188
|
item: busy.entry.item,
|
|
157
189
|
worker: slot.index,
|
|
190
|
+
...(message.retry && { retry: message.retry }),
|
|
158
191
|
});
|
|
159
192
|
case "test-end":
|
|
160
193
|
if (busy?.id !== message.id)
|
|
@@ -20,6 +20,8 @@ export interface WorkerInit {
|
|
|
20
20
|
timeout?: number;
|
|
21
21
|
expectTimeout?: number;
|
|
22
22
|
headless?: boolean;
|
|
23
|
+
/** How many times a failing test is run again in this worker. @default 0 */
|
|
24
|
+
retries?: number;
|
|
23
25
|
/** Where the run's report goes when it is not the default; screenshots are kept beside it */
|
|
24
26
|
reportPath?: string;
|
|
25
27
|
/** Spec files to import, absolute */
|
|
@@ -48,6 +50,7 @@ export type FromWorker = {
|
|
|
48
50
|
} | {
|
|
49
51
|
type: "test-start";
|
|
50
52
|
id: number;
|
|
53
|
+
retry?: number;
|
|
51
54
|
} | {
|
|
52
55
|
type: "test-end";
|
|
53
56
|
id: number;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { workerProfileDir } from "../browser/launch-flags.js";
|
|
2
|
+
import logger from "../logger/index.js";
|
|
2
3
|
import { withProject } from "./project.js";
|
|
3
4
|
import TestReporter from "./reporter.js";
|
|
4
|
-
import {
|
|
5
|
+
import { AllHooks } from "./all-hooks.js";
|
|
6
|
+
import { allHookFixtures, runOneTest } from "./run-test.js";
|
|
7
|
+
import { runWithRetries } from "./retry.js";
|
|
5
8
|
import { loadSpecs } from "./select.js";
|
|
6
9
|
/**
|
|
7
10
|
* Opens the project for a worker and keeps it open until `close`: `withProject` holds the config, environment
|
|
@@ -26,6 +29,11 @@ export function openSession(init) {
|
|
|
26
29
|
...(headless !== undefined && { headless }),
|
|
27
30
|
}, async (run) => {
|
|
28
31
|
const loaded = await loadSpecs(init.files);
|
|
32
|
+
// The `beforeAll` hooks this worker ran: their `afterAll` hooks run when it closes
|
|
33
|
+
const allHooks = new AllHooks({
|
|
34
|
+
timeout: run.settings.timeout,
|
|
35
|
+
fixtures: (browser) => allHookFixtures(run, browser, workerIndex),
|
|
36
|
+
});
|
|
29
37
|
resolve({
|
|
30
38
|
tests: loaded.all,
|
|
31
39
|
async runTest(test, browser, signal, onEvent) {
|
|
@@ -35,20 +43,25 @@ export function openSession(init) {
|
|
|
35
43
|
output: false,
|
|
36
44
|
onEvent,
|
|
37
45
|
});
|
|
38
|
-
|
|
46
|
+
const options = {
|
|
39
47
|
...(headless !== undefined && { headless }),
|
|
40
48
|
...(init.reportPath !== undefined && {
|
|
41
49
|
reportPath: init.reportPath,
|
|
42
50
|
}),
|
|
43
51
|
port,
|
|
44
52
|
signal,
|
|
45
|
-
}
|
|
53
|
+
};
|
|
54
|
+
await runWithRetries(test, browser, init.retries ?? 0, signal, (sink, attempt) => runOneTest(test, browser, run, options, loaded.srcDir, sink, {
|
|
46
55
|
port,
|
|
47
56
|
profileDir: workerProfileDir(browser, workerIndex),
|
|
48
57
|
workerIndex,
|
|
49
|
-
|
|
58
|
+
allHooks,
|
|
59
|
+
attempt,
|
|
60
|
+
}), reporter);
|
|
50
61
|
},
|
|
51
62
|
async close() {
|
|
63
|
+
for (const error of await allHooks.closeAll())
|
|
64
|
+
logger.error("%s", error.message);
|
|
52
65
|
release();
|
|
53
66
|
await finished;
|
|
54
67
|
},
|
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,16 +44,40 @@ function callbackBody(node) {
|
|
|
44
44
|
? node.body
|
|
45
45
|
: undefined;
|
|
46
46
|
}
|
|
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) &&
|
|
52
|
+
ts.isIdentifier(expression.expression) &&
|
|
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));
|
|
65
|
+
}
|
|
47
66
|
/** The tests in a spec file, with their steps and where each statement sits */
|
|
48
67
|
export function parseSpec(source) {
|
|
49
68
|
const sourceFile = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
50
69
|
const tests = [];
|
|
51
|
-
const visit = (node, titles) => {
|
|
52
|
-
|
|
70
|
+
const visit = (node, titles, describes) => {
|
|
71
|
+
const callee = ts.isCallExpression(node)
|
|
72
|
+
? calleeOf(node.expression)
|
|
73
|
+
: undefined;
|
|
74
|
+
if (ts.isCallExpression(node) && callee) {
|
|
53
75
|
const title = stringOf(node.arguments[0]);
|
|
54
76
|
const body = callbackBody(node.arguments[1]);
|
|
55
|
-
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
|
|
56
79
|
const titlePath = [...titles, title];
|
|
80
|
+
const modifier = effectiveModifier(callee.modifier, describes);
|
|
57
81
|
tests.push({
|
|
58
82
|
titlePath,
|
|
59
83
|
name: titlePath.join(" > "),
|
|
@@ -61,16 +85,17 @@ export function parseSpec(source) {
|
|
|
61
85
|
bodyStart: body.getStart(sourceFile) + 1,
|
|
62
86
|
bodyEnd: body.end - 1,
|
|
63
87
|
fixtures: callbackFixtures(node.arguments[1], sourceFile),
|
|
88
|
+
...(modifier && { modifier }),
|
|
64
89
|
});
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
67
|
-
if (title !== undefined && body &&
|
|
68
|
-
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]));
|
|
69
94
|
return;
|
|
70
95
|
}
|
|
71
96
|
}
|
|
72
|
-
ts.forEachChild(node, (child) => visit(child, titles));
|
|
97
|
+
ts.forEachChild(node, (child) => visit(child, titles, describes));
|
|
73
98
|
};
|
|
74
|
-
visit(sourceFile, []);
|
|
99
|
+
visit(sourceFile, [], []);
|
|
75
100
|
return tests;
|
|
76
101
|
}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -28,7 +28,8 @@ export interface SamuraiGroup {
|
|
|
28
28
|
browsers?: SupportedBrowser[];
|
|
29
29
|
/**
|
|
30
30
|
* How many of this group's tests may run at the same time. Only `1` has an effect: the group's tests run one
|
|
31
|
-
* after the other, even when the run uses more workers (for tests that share an account or data).
|
|
31
|
+
* after the other, even when the run uses more workers (for tests that share an account or data). A run of
|
|
32
|
+
* nothing but such a group uses one worker.
|
|
32
33
|
*/
|
|
33
34
|
workers?: number;
|
|
34
35
|
}
|
|
@@ -63,6 +64,19 @@ export interface SamuraiTestConfig {
|
|
|
63
64
|
* @default half the CPUs
|
|
64
65
|
*/
|
|
65
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;
|
|
74
|
+
/**
|
|
75
|
+
* Refuse a run when a spec contains `test.only` or `describe.only`, so a forgotten focus cannot quietly shrink a
|
|
76
|
+
* suite. The run stops before any test starts and lists the tests.
|
|
77
|
+
* @default true when the `CI` environment variable is set, else false
|
|
78
|
+
*/
|
|
79
|
+
forbidOnly?: boolean;
|
|
66
80
|
/**
|
|
67
81
|
* Browser binaries to launch instead of the builds SAMURAI downloads, relative to the project.
|
|
68
82
|
* `SAMURAI_FIREFOX_PATH` / `SAMURAI_CHROME_PATH` win over these.
|
package/dist/types/test.d.ts
CHANGED
|
@@ -18,11 +18,42 @@ export interface TestFixtures {
|
|
|
18
18
|
|
|
19
19
|
export type TestCase = TestCaseBase;
|
|
20
20
|
|
|
21
|
+
/** `skip`: not run. `fixme`: not run, known to be broken. `only`: the only test that runs when any is focused */
|
|
22
|
+
export type Modifier = "skip" | "fixme" | "only";
|
|
23
|
+
|
|
24
|
+
/** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
|
|
25
|
+
export type EachHook = (fixtures: TestFixtures) => Promise<void> | void;
|
|
26
|
+
|
|
27
|
+
/** What `beforeAll` and `afterAll` receive: no page, as they run once for many tests */
|
|
28
|
+
export type AllHookFixtures = Pick<
|
|
29
|
+
TestFixtures,
|
|
30
|
+
"env" | "secrets" | "browserName" | "workerIndex"
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
/** Runs once per worker before the first test of a suite, or after the last one the worker ran */
|
|
34
|
+
export type AllHook = (fixtures: AllHookFixtures) => Promise<void> | void;
|
|
35
|
+
|
|
36
|
+
/** A spec file (the root) or a `describe` block: what a modifier or a hook applies to */
|
|
37
|
+
export interface Suite {
|
|
38
|
+
/** The `describe` title; the root of a file has none */
|
|
39
|
+
title?: string;
|
|
40
|
+
parent?: Suite;
|
|
41
|
+
modifier?: Modifier;
|
|
42
|
+
beforeEach?: EachHook[];
|
|
43
|
+
afterEach?: EachHook[];
|
|
44
|
+
beforeAll?: AllHook[];
|
|
45
|
+
afterAll?: AllHook[];
|
|
46
|
+
}
|
|
47
|
+
|
|
21
48
|
export interface RegisteredTestCase extends TestCaseBase {
|
|
22
49
|
/** Path of the spec file that registered the test */
|
|
23
50
|
file: string;
|
|
24
51
|
/** describe titles, then the test title */
|
|
25
52
|
titlePath: string[];
|
|
53
|
+
/** The `describe` block (or the file) the test is written in */
|
|
54
|
+
suite: Suite;
|
|
55
|
+
/** What applies to the test: its own modifier, or the one of a suite around it */
|
|
56
|
+
modifier?: Modifier;
|
|
26
57
|
}
|
|
27
58
|
|
|
28
59
|
export interface TestCaseBase {
|
|
@@ -36,9 +67,10 @@ export interface PartialTestResult extends TestResultBase {
|
|
|
36
67
|
file: string;
|
|
37
68
|
}
|
|
38
69
|
|
|
39
|
-
export type TestResult =
|
|
70
|
+
export type TestResult =
|
|
71
|
+
TestResultSuccess | TestResultFailed | TestResultSkipped | TestResultBase;
|
|
40
72
|
|
|
41
|
-
interface TestResultSuccess extends TestResultIdentity {
|
|
73
|
+
interface TestResultSuccess extends TestResultIdentity, TestRetries {
|
|
42
74
|
status: "success";
|
|
43
75
|
duration: number;
|
|
44
76
|
logs?: TestLogEntry[];
|
|
@@ -49,7 +81,7 @@ interface TestResultSuccess extends TestResultIdentity {
|
|
|
49
81
|
}
|
|
50
82
|
|
|
51
83
|
/** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
|
|
52
|
-
interface TestResultFailed extends TestResultIdentity, TestError {
|
|
84
|
+
interface TestResultFailed extends TestResultIdentity, TestError, TestRetries {
|
|
53
85
|
status: "failed";
|
|
54
86
|
duration: number;
|
|
55
87
|
logs?: TestLogEntry[];
|
|
@@ -58,6 +90,25 @@ interface TestResultFailed extends TestResultIdentity, TestError {
|
|
|
58
90
|
screenshots?: { name: string; path: string }[];
|
|
59
91
|
}
|
|
60
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
|
+
|
|
103
|
+
/** A test that was not run: marked `skip` or `fixme`, or skipped from inside with `test.skip(condition)` */
|
|
104
|
+
interface TestResultSkipped extends TestResultIdentity {
|
|
105
|
+
status: "skipped";
|
|
106
|
+
duration: 0;
|
|
107
|
+
/** `"fixme"` for a test known to be broken, `"skip"` for the rest */
|
|
108
|
+
annotation: "skip" | "fixme";
|
|
109
|
+
reason?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
61
112
|
/** One line of the browser's log, as stored in the report */
|
|
62
113
|
export interface TestLogEntry {
|
|
63
114
|
level: "debug" | "info" | "warn" | "error";
|
|
@@ -100,6 +151,7 @@ export interface TestError {
|
|
|
100
151
|
export type TestSummary = TestSummarySuccess | TestSummaryFailed;
|
|
101
152
|
|
|
102
153
|
interface TestSummarySuccess extends TestSummaryBase {
|
|
154
|
+
/** No test failed (skipped tests do not count as failed) */
|
|
103
155
|
status: "success";
|
|
104
156
|
}
|
|
105
157
|
|