@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
|
@@ -2,7 +2,8 @@ import path from "node:path";
|
|
|
2
2
|
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
|
-
import logger from "../../logger/index.js";
|
|
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,28 +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
|
-
return results.every((r) => r.status === "success") ? "passed" : "failed";
|
|
60
|
-
}
|
|
61
35
|
/** The tests, and the browsers, of each Test and Group node of a flow; a Test node uses the project's browsers */
|
|
62
36
|
export function nodeTestsOf(flow, tests, groups) {
|
|
63
37
|
const byId = new Map(tests.map((t) => [t.id, t]));
|
|
@@ -152,13 +126,7 @@ export async function checkFlowInProject(options) {
|
|
|
152
126
|
const { flow, problems } = await prepare(options);
|
|
153
127
|
return { flow, problems };
|
|
154
128
|
}
|
|
155
|
-
|
|
156
|
-
* Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
|
|
157
|
-
* when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
|
|
158
|
-
* its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
|
|
159
|
-
* One at a time per process.
|
|
160
|
-
*/
|
|
161
|
-
export async function runFlowInProject(options) {
|
|
129
|
+
async function walkFlowInProject(options) {
|
|
162
130
|
const { projectDir, flow, unsupported, config, steps, name, environment, tests, groups, problems, } = await prepare(options);
|
|
163
131
|
const errors = problems.filter((p) => p.level === "error");
|
|
164
132
|
if (errors.length)
|
|
@@ -185,6 +153,12 @@ export async function runFlowInProject(options) {
|
|
|
185
153
|
const files = [
|
|
186
154
|
...new Set(Object.values(nodeTests).flatMap((list) => list.map((t) => t.file))),
|
|
187
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
|
+
});
|
|
188
162
|
// One pool for the whole run: the nodes, and the branches of a Parallel node, share its workers
|
|
189
163
|
const pool = workers > 1
|
|
190
164
|
? (options.createPool ?? ((o) => new WorkerPool(o)))({
|
|
@@ -203,6 +177,7 @@ export async function runFlowInProject(options) {
|
|
|
203
177
|
}),
|
|
204
178
|
files,
|
|
205
179
|
basePort: options.port ?? DEFAULT_PORT,
|
|
180
|
+
...(retries > 0 && { retries }),
|
|
206
181
|
},
|
|
207
182
|
})
|
|
208
183
|
: undefined;
|
|
@@ -233,6 +208,7 @@ export async function runFlowInProject(options) {
|
|
|
233
208
|
expectTimeout: options.expectTimeout,
|
|
234
209
|
}),
|
|
235
210
|
...(options.port !== undefined && { port: options.port }),
|
|
211
|
+
...(retries > 0 && { retries }),
|
|
236
212
|
...(browsers && { browsers }),
|
|
237
213
|
files: [...new Set(tests.map((t) => t.file))],
|
|
238
214
|
testNames: tests.map((t) => t.name),
|
|
@@ -250,3 +226,19 @@ export async function runFlowInProject(options) {
|
|
|
250
226
|
await pool?.dispose();
|
|
251
227
|
}
|
|
252
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
|
|
231
|
+
* when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
|
|
232
|
+
* its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
|
|
233
|
+
* One at a time per process.
|
|
234
|
+
*/
|
|
235
|
+
export async function runFlowInProject(options) {
|
|
236
|
+
// One log for the whole run, across the project sessions it opens one after the other
|
|
237
|
+
const releaseLogs = keepLogFiles(path.resolve(options.projectDir ?? process.cwd()));
|
|
238
|
+
try {
|
|
239
|
+
return await walkFlowInProject(options);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
releaseLogs();
|
|
243
|
+
}
|
|
244
|
+
}
|
package/dist/logger/index.d.ts
CHANGED
|
@@ -5,4 +5,10 @@ export declare function consoleLine(entry: {
|
|
|
5
5
|
meta?: Record<string, unknown> | undefined;
|
|
6
6
|
}): string;
|
|
7
7
|
declare const logger: import("winston").Logger;
|
|
8
|
+
/**
|
|
9
|
+
* Writes the log to `error.log` and `combined.log` in `<projectDir>/logs/` until the returned function is called.
|
|
10
|
+
* Nothing is written to disk before a project is in use, so a command that has none (`samurai --version`) leaves
|
|
11
|
+
* no folder behind. Holders of the same project share the files; the files close with the last one.
|
|
12
|
+
*/
|
|
13
|
+
export declare function keepLogFiles(projectDir: string): () => void;
|
|
8
14
|
export default logger;
|
package/dist/logger/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { createLogger, format, transports } from "winston";
|
|
2
3
|
import { maskFormat } from "../config/mask.js";
|
|
3
4
|
/** Metadata every line carries (`defaultMeta`): not worth printing on each console line */
|
|
@@ -15,7 +16,7 @@ const logger = createLogger({
|
|
|
15
16
|
}), format.errors({ stack: true }), format.splat(), format.metadata({ key: "meta" }), maskFormat(), format.json({ space: 2 })),
|
|
16
17
|
defaultMeta: { service: "samurai-framework" },
|
|
17
18
|
transports: [
|
|
18
|
-
// The console shows the log; `logs
|
|
19
|
+
// The console shows the log; the files of a project's `logs/` folder are added while a project is in use (`keepLogFiles`)
|
|
19
20
|
new transports.Console({
|
|
20
21
|
// The `samurai` command quiets this to "warn"; the log files keep everything
|
|
21
22
|
level: process.env.SAMURAI_LOG_LEVEL ?? "debug",
|
|
@@ -27,11 +28,51 @@ const logger = createLogger({
|
|
|
27
28
|
meta: meta,
|
|
28
29
|
}))),
|
|
29
30
|
}),
|
|
30
|
-
new transports.File({
|
|
31
|
-
filename: `logs/error.log`,
|
|
32
|
-
level: "error",
|
|
33
|
-
}),
|
|
34
|
-
new transports.File({ filename: `logs/combined.log` }),
|
|
35
31
|
],
|
|
36
32
|
});
|
|
33
|
+
/** The files the log is written to now: `error.log` keeps errors, `combined.log` everything */
|
|
34
|
+
let files;
|
|
35
|
+
/**
|
|
36
|
+
* Writes the log to `error.log` and `combined.log` in `<projectDir>/logs/` until the returned function is called.
|
|
37
|
+
* Nothing is written to disk before a project is in use, so a command that has none (`samurai --version`) leaves
|
|
38
|
+
* no folder behind. Holders of the same project share the files; the files close with the last one.
|
|
39
|
+
*/
|
|
40
|
+
export function keepLogFiles(projectDir) {
|
|
41
|
+
const dir = path.join(projectDir, "logs");
|
|
42
|
+
if (files && files.dir !== dir)
|
|
43
|
+
closeFiles();
|
|
44
|
+
files ??= {
|
|
45
|
+
dir,
|
|
46
|
+
transports: [
|
|
47
|
+
new transports.File({
|
|
48
|
+
filename: path.join(dir, "error.log"),
|
|
49
|
+
level: "error",
|
|
50
|
+
}),
|
|
51
|
+
new transports.File({ filename: path.join(dir, "combined.log") }),
|
|
52
|
+
],
|
|
53
|
+
holders: 0,
|
|
54
|
+
};
|
|
55
|
+
const mine = files;
|
|
56
|
+
if (mine.holders === 0)
|
|
57
|
+
for (const t of mine.transports)
|
|
58
|
+
logger.add(t);
|
|
59
|
+
mine.holders++;
|
|
60
|
+
let released = false;
|
|
61
|
+
return () => {
|
|
62
|
+
if (released)
|
|
63
|
+
return;
|
|
64
|
+
released = true;
|
|
65
|
+
if (--mine.holders === 0 && files === mine)
|
|
66
|
+
closeFiles();
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function closeFiles() {
|
|
70
|
+
if (!files)
|
|
71
|
+
return;
|
|
72
|
+
for (const t of files.transports) {
|
|
73
|
+
logger.remove(t);
|
|
74
|
+
t.close?.();
|
|
75
|
+
}
|
|
76
|
+
files = undefined;
|
|
77
|
+
}
|
|
37
78
|
export default logger;
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SupportedBrowser } from "../types/browser.js";
|
|
2
|
+
import type { AllHookFixtures, RegisteredTestCase } from "../types/test.js";
|
|
3
|
+
import { type HookError } from "./hooks.js";
|
|
4
|
+
export interface AllHooksOptions {
|
|
5
|
+
/** The limit of each beforeAll / afterAll hook, in ms */
|
|
6
|
+
timeout: number;
|
|
7
|
+
/** The fixtures the hooks of a browser get */
|
|
8
|
+
fixtures(browser: SupportedBrowser): AllHookFixtures;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The `beforeAll` and `afterAll` hooks of one worker: each runs once per suite and browser, `beforeAll` when the
|
|
12
|
+
* first test of the suite arrives and `afterAll` when the worker is done
|
|
13
|
+
*/
|
|
14
|
+
export declare class AllHooks {
|
|
15
|
+
private readonly options;
|
|
16
|
+
private readonly scopes;
|
|
17
|
+
/** In the order the suites were entered */
|
|
18
|
+
private readonly entered;
|
|
19
|
+
constructor(options: AllHooksOptions);
|
|
20
|
+
/** Runs the `beforeAll` hooks of the suites around `test` that this worker has not run for `browser`. Rejects with the `HookError` of one that failed, now or earlier */
|
|
21
|
+
ensure(test: RegisteredTestCase, browser: SupportedBrowser): Promise<void>;
|
|
22
|
+
/** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
|
|
23
|
+
closeAll(): Promise<HookError[]>;
|
|
24
|
+
private enter;
|
|
25
|
+
private runBefore;
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { runHook } from "./hooks.js";
|
|
2
|
+
import { suiteChain } from "./suite.js";
|
|
3
|
+
/**
|
|
4
|
+
* The `beforeAll` and `afterAll` hooks of one worker: each runs once per suite and browser, `beforeAll` when the
|
|
5
|
+
* first test of the suite arrives and `afterAll` when the worker is done
|
|
6
|
+
*/
|
|
7
|
+
export class AllHooks {
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.scopes = new Map();
|
|
11
|
+
/** In the order the suites were entered */
|
|
12
|
+
this.entered = [];
|
|
13
|
+
}
|
|
14
|
+
/** Runs the `beforeAll` hooks of the suites around `test` that this worker has not run for `browser`. Rejects with the `HookError` of one that failed, now or earlier */
|
|
15
|
+
async ensure(test, browser) {
|
|
16
|
+
for (const suite of suiteChain(test.suite)) {
|
|
17
|
+
if (!suite.beforeAll?.length && !suite.afterAll?.length)
|
|
18
|
+
continue;
|
|
19
|
+
await this.enter(suite, browser).ready;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Runs the `afterAll` hooks of every suite entered, last entered first, and returns the errors; a second call has nothing to do */
|
|
23
|
+
async closeAll() {
|
|
24
|
+
const errors = [];
|
|
25
|
+
const entered = this.entered.splice(0).reverse();
|
|
26
|
+
this.scopes.clear();
|
|
27
|
+
for (const { suite, browser, ready } of entered) {
|
|
28
|
+
await ready.catch(() => undefined);
|
|
29
|
+
for (const hook of suite.afterAll ?? []) {
|
|
30
|
+
try {
|
|
31
|
+
await runHook("afterAll", hook, this.options.fixtures(browser), this.options.timeout);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
errors.push(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return errors;
|
|
39
|
+
}
|
|
40
|
+
enter(suite, browser) {
|
|
41
|
+
const ofSuite = this.scopes.get(suite) ?? new Map();
|
|
42
|
+
this.scopes.set(suite, ofSuite);
|
|
43
|
+
const known = ofSuite.get(browser);
|
|
44
|
+
if (known)
|
|
45
|
+
return known;
|
|
46
|
+
const scope = {
|
|
47
|
+
suite,
|
|
48
|
+
browser,
|
|
49
|
+
ready: this.runBefore(suite, browser),
|
|
50
|
+
};
|
|
51
|
+
// Rejections are handled by the callers of `ensure`; one nobody waits for is not an error of the process
|
|
52
|
+
scope.ready.catch(() => undefined);
|
|
53
|
+
ofSuite.set(browser, scope);
|
|
54
|
+
this.entered.push(scope);
|
|
55
|
+
return scope;
|
|
56
|
+
}
|
|
57
|
+
async runBefore(suite, browser) {
|
|
58
|
+
for (const hook of suite.beforeAll ?? [])
|
|
59
|
+
await runHook("beforeAll", hook, this.options.fixtures(browser), this.options.timeout);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RegisteredTestCase } from "../types/test.js";
|
|
2
|
+
/** `test.only` is in the specs and the config refuses it: one error that lists every focused test */
|
|
3
|
+
export declare function assertNoOnly(all: readonly RegisteredTestCase[], forbid: boolean, srcDir: string): void;
|
|
4
|
+
/** When any of the selected tests is focused, only the focused ones; the others are not run and not reported */
|
|
5
|
+
export declare function narrowToOnly(selected: RegisteredTestCase[]): RegisteredTestCase[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { toPosix } from "./groups.js";
|
|
3
|
+
/** `test.only` is in the specs and the config refuses it: one error that lists every focused test */
|
|
4
|
+
export function assertNoOnly(all, forbid, srcDir) {
|
|
5
|
+
if (!forbid)
|
|
6
|
+
return;
|
|
7
|
+
const focused = all.filter((test) => test.modifier === "only");
|
|
8
|
+
if (focused.length === 0)
|
|
9
|
+
return;
|
|
10
|
+
throw new Error(focused
|
|
11
|
+
.map((test) => `test.only is not allowed (forbidOnly): ${toPosix(path.relative(srcDir, test.file))}: ${test.name}`)
|
|
12
|
+
.join("\n"));
|
|
13
|
+
}
|
|
14
|
+
/** When any of the selected tests is focused, only the focused ones; the others are not run and not reported */
|
|
15
|
+
export function narrowToOnly(selected) {
|
|
16
|
+
return selected.some((test) => test.modifier === "only")
|
|
17
|
+
? selected.filter((test) => test.modifier === "only")
|
|
18
|
+
: selected;
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Suite, TestFixtures } from "../types/test.js";
|
|
2
|
+
import { type HookKind } from "./suite.js";
|
|
3
|
+
/** A hook threw or ran out of time: the message names the hook (`beforeEach hook failed: <message>`) */
|
|
4
|
+
export declare class HookError extends Error {
|
|
5
|
+
readonly kind: HookKind;
|
|
6
|
+
readonly cause: unknown;
|
|
7
|
+
constructor(kind: HookKind, cause: unknown);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Runs one hook with its fixtures and waits for it. Anything it throws, and a hook that is still running after
|
|
11
|
+
* `limitMs` (when given), becomes a `HookError` of that kind.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runHook<F>(kind: HookKind, hook: (fixtures: F) => Promise<void> | void, fixtures: F, limitMs?: number): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Runs a test body between the `beforeEach` and `afterEach` hooks of its suites.
|
|
16
|
+
*
|
|
17
|
+
* - A failing `beforeEach` stops the other `beforeEach` hooks and the body; the `afterEach` hooks still run.
|
|
18
|
+
* - The `afterEach` hooks run when the body ended by itself (it passed, threw or skipped), not when `signal` says
|
|
19
|
+
* the test was cut short (timeout, abort, closed browser): the time or the page is gone. They all run, even
|
|
20
|
+
* after one fails.
|
|
21
|
+
* - What it throws: the `beforeEach` failure, else the body's error, else the first `afterEach` failure. A skip is
|
|
22
|
+
* overruled by a failing `afterEach`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function runWithEachHooks(suite: Suite, fixtures: TestFixtures, signal: AbortSignal, body: () => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isSkipSignal } from "./skip.js";
|
|
2
|
+
import { afterEachOf, beforeEachOf } from "./suite.js";
|
|
3
|
+
/** A hook threw or ran out of time: the message names the hook (`beforeEach hook failed: <message>`) */
|
|
4
|
+
export class HookError extends Error {
|
|
5
|
+
constructor(kind, cause) {
|
|
6
|
+
super(`${kind} hook failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
this.cause = cause;
|
|
9
|
+
this.name = "HookError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Runs one hook with its fixtures and waits for it. Anything it throws, and a hook that is still running after
|
|
14
|
+
* `limitMs` (when given), becomes a `HookError` of that kind.
|
|
15
|
+
*/
|
|
16
|
+
export async function runHook(kind, hook, fixtures, limitMs) {
|
|
17
|
+
let timer;
|
|
18
|
+
try {
|
|
19
|
+
const running = Promise.resolve().then(() => hook(fixtures));
|
|
20
|
+
if (limitMs === undefined)
|
|
21
|
+
return await running;
|
|
22
|
+
await Promise.race([
|
|
23
|
+
running,
|
|
24
|
+
new Promise((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${limitMs / 1000} seconds`)), limitMs);
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// `test.skip(condition)` in a hook skips the test: it is not a failure of the hook
|
|
31
|
+
if (isSkipSignal(error))
|
|
32
|
+
throw error;
|
|
33
|
+
throw new HookError(kind, error);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Runs a test body between the `beforeEach` and `afterEach` hooks of its suites.
|
|
41
|
+
*
|
|
42
|
+
* - A failing `beforeEach` stops the other `beforeEach` hooks and the body; the `afterEach` hooks still run.
|
|
43
|
+
* - The `afterEach` hooks run when the body ended by itself (it passed, threw or skipped), not when `signal` says
|
|
44
|
+
* the test was cut short (timeout, abort, closed browser): the time or the page is gone. They all run, even
|
|
45
|
+
* after one fails.
|
|
46
|
+
* - What it throws: the `beforeEach` failure, else the body's error, else the first `afterEach` failure. A skip is
|
|
47
|
+
* overruled by a failing `afterEach`.
|
|
48
|
+
*/
|
|
49
|
+
export async function runWithEachHooks(suite, fixtures, signal, body) {
|
|
50
|
+
let primary;
|
|
51
|
+
let failed = false;
|
|
52
|
+
try {
|
|
53
|
+
for (const hook of beforeEachOf(suite))
|
|
54
|
+
await runHook("beforeEach", hook, fixtures);
|
|
55
|
+
await body();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
primary = error;
|
|
59
|
+
failed = true;
|
|
60
|
+
}
|
|
61
|
+
let afterError;
|
|
62
|
+
let afterFailed = false;
|
|
63
|
+
if (!signal.aborted) {
|
|
64
|
+
for (const hook of afterEachOf(suite)) {
|
|
65
|
+
try {
|
|
66
|
+
await runHook("afterEach", hook, fixtures);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (!afterFailed)
|
|
70
|
+
afterError = error;
|
|
71
|
+
afterFailed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (afterFailed && (!failed || isSkipSignal(primary)))
|
|
76
|
+
throw afterError;
|
|
77
|
+
if (failed)
|
|
78
|
+
throw primary;
|
|
79
|
+
}
|
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) */
|