@itmetsam/samurai-framework 0.2.3 → 0.4.0
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 +5 -0
- package/bin/samurai.mjs +7 -5
- package/dist/cli/args.d.ts +5 -1
- package/dist/cli/args.js +6 -1
- package/dist/cli/flow-reporter.d.ts +23 -0
- package/dist/cli/flow-reporter.js +98 -0
- package/dist/cli/flow.d.ts +4 -0
- package/dist/cli/flow.js +240 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +14 -0
- package/dist/flows/core/check.d.ts +45 -0
- package/dist/flows/core/check.js +205 -0
- package/dist/flows/core/expression/check.d.ts +23 -0
- package/dist/flows/core/expression/check.js +95 -0
- package/dist/flows/core/expression/evaluate.d.ts +22 -0
- package/dist/flows/core/expression/evaluate.js +141 -0
- package/dist/flows/core/expression/help.d.ts +25 -0
- package/dist/flows/core/expression/help.js +75 -0
- package/dist/flows/core/expression/index.d.ts +5 -0
- package/dist/flows/core/expression/index.js +5 -0
- package/dist/flows/core/expression/parse.d.ts +61 -0
- package/dist/flows/core/expression/parse.js +317 -0
- package/dist/flows/core/expression/scope.d.ts +8 -0
- package/dist/flows/core/expression/scope.js +34 -0
- package/dist/flows/core/graph.d.ts +11 -0
- package/dist/flows/core/graph.js +65 -0
- package/dist/flows/core/index.d.ts +17 -0
- package/dist/flows/core/index.js +17 -0
- package/dist/flows/core/keys.d.ts +8 -0
- package/dist/flows/core/keys.js +46 -0
- package/dist/flows/core/nodes/condition.d.ts +7 -0
- package/dist/flows/core/nodes/condition.js +21 -0
- package/dist/flows/core/nodes/define.d.ts +132 -0
- package/dist/flows/core/nodes/define.js +21 -0
- package/dist/flows/core/nodes/end.d.ts +1 -0
- package/dist/flows/core/nodes/end.js +7 -0
- package/dist/flows/core/nodes/group.d.ts +5 -0
- package/dist/flows/core/nodes/group.js +18 -0
- package/dist/flows/core/nodes/index.d.ts +45 -0
- package/dist/flows/core/nodes/index.js +55 -0
- package/dist/flows/core/nodes/parallel.d.ts +1 -0
- package/dist/flows/core/nodes/parallel.js +11 -0
- package/dist/flows/core/nodes/path.d.ts +5 -0
- package/dist/flows/core/nodes/path.js +21 -0
- package/dist/flows/core/nodes/start.d.ts +1 -0
- package/dist/flows/core/nodes/start.js +7 -0
- package/dist/flows/core/nodes/test.d.ts +1 -0
- package/dist/flows/core/nodes/test.js +18 -0
- package/dist/flows/core/schema.d.ts +55 -0
- package/dist/flows/core/schema.js +33 -0
- package/dist/flows/core/steps.d.ts +42 -0
- package/dist/flows/core/steps.js +47 -0
- package/dist/flows/run/events.d.ts +45 -0
- package/dist/flows/run/events.js +1 -0
- package/dist/flows/run/files.d.ts +12 -0
- package/dist/flows/run/files.js +54 -0
- package/dist/flows/run/handlers/condition.d.ts +2 -0
- package/dist/flows/run/handlers/condition.js +17 -0
- package/dist/flows/run/handlers/end.d.ts +2 -0
- package/dist/flows/run/handlers/end.js +1 -0
- package/dist/flows/run/handlers/group.d.ts +2 -0
- package/dist/flows/run/handlers/group.js +7 -0
- package/dist/flows/run/handlers/index.d.ts +4 -0
- package/dist/flows/run/handlers/index.js +17 -0
- package/dist/flows/run/handlers/parallel.d.ts +3 -0
- package/dist/flows/run/handlers/parallel.js +2 -0
- package/dist/flows/run/handlers/start.d.ts +2 -0
- package/dist/flows/run/handlers/start.js +1 -0
- package/dist/flows/run/handlers/support.d.ts +29 -0
- package/dist/flows/run/handlers/support.js +22 -0
- package/dist/flows/run/handlers/test.d.ts +2 -0
- package/dist/flows/run/handlers/test.js +7 -0
- package/dist/flows/run/index.d.ts +8 -0
- package/dist/flows/run/index.js +7 -0
- package/dist/flows/run/project.d.ts +43 -0
- package/dist/flows/run/project.js +165 -0
- package/dist/flows/run/report.d.ts +39 -0
- package/dist/flows/run/report.js +46 -0
- package/dist/flows/run/run.d.ts +42 -0
- package/dist/flows/run/run.js +250 -0
- package/dist/flows/steps/api.d.ts +23 -0
- package/dist/flows/steps/api.js +40 -0
- package/dist/flows/steps/catalog.d.ts +16 -0
- package/dist/flows/steps/catalog.js +29 -0
- package/dist/flows/steps/define.d.ts +49 -0
- package/dist/flows/steps/define.js +40 -0
- package/dist/flows/steps/handler.d.ts +4 -0
- package/dist/flows/steps/handler.js +70 -0
- package/dist/flows/steps/index.d.ts +5 -0
- package/dist/flows/steps/index.js +4 -0
- package/dist/flows/steps/set-variable.d.ts +16 -0
- package/dist/flows/steps/set-variable.js +20 -0
- package/dist/flows/steps/wait.d.ts +8 -0
- package/dist/flows/steps/wait.js +14 -0
- package/dist/runner/groups.d.ts +39 -0
- package/dist/runner/groups.js +58 -0
- package/dist/runner/run.d.ts +4 -0
- package/dist/runner/run.js +12 -0
- package/dist/runner/test-runner.d.ts +12 -2
- package/dist/runner/test-runner.js +36 -6
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -92,6 +92,10 @@ A browser window opens for each test and closes when the test ends. The result i
|
|
|
92
92
|
|
|
93
93
|
> `src/tests/index.spec.ts` is an example that submits a real contact form. Delete it or replace it before running everything.
|
|
94
94
|
|
|
95
|
+
### Flows (optional)
|
|
96
|
+
|
|
97
|
+
A project can also keep flows in `flows/*.flow.json` (edited in the Samurai app) that chain tests and groups with conditions and variables. `samurai flow check` validates them and `samurai flow run <flow>` runs them, with a report in `result/flows/`. See [Flows](docs/flows.md).
|
|
98
|
+
|
|
95
99
|
### 6. Secrets (optional)
|
|
96
100
|
|
|
97
101
|
```sh
|
|
@@ -113,6 +117,7 @@ Read it in a test as `secrets.TEST_PASSWORD`. Real environment variables win ove
|
|
|
113
117
|
| [Configuration](docs/configuration.md) | Every option in `samurai.config.ts`, CLI flags and precedence |
|
|
114
118
|
| [Environments and secrets](docs/environments-and-secrets.md) | Per-environment settings, variables, secrets and masking |
|
|
115
119
|
| [Command line](docs/cli.md) | `samurai run`, `list`, `record` and `init`, options, output, exit codes, `--json` |
|
|
120
|
+
| [Flows](docs/flows.md) | `samurai flow list`, `check` and `run`: graphs of tests, groups and steps, reports, CI |
|
|
116
121
|
| [Running tests and reports](docs/running-and-reports.md) | What a run does, `report.json`, page logs, timeouts |
|
|
117
122
|
| [Steps and the recorder](docs/recorder.md) | The step codec and the recording engine |
|
|
118
123
|
| [Embedding the framework](docs/embedding.md) | Using the package from another app (such as the Electron UI): build, entry points, `runTests`, events |
|
package/bin/samurai.mjs
CHANGED
|
@@ -15,12 +15,14 @@ try {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const { main } = await import("../dist/cli/main.js");
|
|
18
|
-
// Ctrl+C ends a recording cleanly (the browser is closed, the file is complete); a second one stops at once
|
|
18
|
+
// Ctrl+C (or a termination signal) ends a recording or a flow run cleanly (the browser is closed, the file is complete); a second one stops at once
|
|
19
19
|
const interrupt = new AbortController();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
21
|
+
process.on(signal, () => {
|
|
22
|
+
if (interrupt.signal.aborted) process.exit(130);
|
|
23
|
+
interrupt.abort();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
24
26
|
const code = await main(process.argv.slice(2), {
|
|
25
27
|
out: (text) => process.stdout.write(text),
|
|
26
28
|
err: (text) => process.stderr.write(text),
|
package/dist/cli/args.d.ts
CHANGED
|
@@ -10,8 +10,11 @@ export declare function parseCommandLine(argv: string[]): {
|
|
|
10
10
|
headless?: boolean | undefined;
|
|
11
11
|
port?: string | undefined;
|
|
12
12
|
grep?: string | undefined;
|
|
13
|
+
group?: string | undefined;
|
|
13
14
|
file?: string[] | undefined;
|
|
14
15
|
json?: boolean | undefined;
|
|
16
|
+
all?: boolean | undefined;
|
|
17
|
+
"allow-unsupported"?: boolean | undefined;
|
|
15
18
|
test?: string | undefined;
|
|
16
19
|
at?: string | undefined;
|
|
17
20
|
new?: string | undefined;
|
|
@@ -26,9 +29,10 @@ export interface RunnerFlags {
|
|
|
26
29
|
headless?: boolean;
|
|
27
30
|
port?: number;
|
|
28
31
|
grep?: string;
|
|
32
|
+
group?: string;
|
|
29
33
|
files?: string[];
|
|
30
34
|
}
|
|
31
|
-
/** Reads `--headless`, `--port <n>`, `--grep <text>` and `--file <spec>` (repeatable) from `argv` */
|
|
35
|
+
/** Reads `--headless`, `--port <n>`, `--grep <text>`, `--group <name>` and `--file <spec>` (repeatable) from `argv` */
|
|
32
36
|
export declare function parseRunnerFlags(argv: string[]): RunnerFlags;
|
|
33
37
|
/** Reads `--env`, `--timeout` and `--expect-timeout` from `argv`; the environment falls back to SAMURAI_ENV */
|
|
34
38
|
export declare function parseRunOverrides(argv: string[], env?: Partial<NodeJS.ProcessEnv>): RunOverrides;
|
package/dist/cli/args.js
CHANGED
|
@@ -6,8 +6,11 @@ const FLAGS = {
|
|
|
6
6
|
headless: { type: "boolean" },
|
|
7
7
|
port: { type: "string" },
|
|
8
8
|
grep: { type: "string" },
|
|
9
|
+
group: { type: "string" },
|
|
9
10
|
file: { type: "string", multiple: true },
|
|
10
11
|
json: { type: "boolean" },
|
|
12
|
+
all: { type: "boolean" },
|
|
13
|
+
"allow-unsupported": { type: "boolean" },
|
|
11
14
|
test: { type: "string" },
|
|
12
15
|
at: { type: "string" },
|
|
13
16
|
new: { type: "string" },
|
|
@@ -28,7 +31,7 @@ export function parseCommandLine(argv) {
|
|
|
28
31
|
function parseFlags(argv) {
|
|
29
32
|
return parseCommandLine(argv).values;
|
|
30
33
|
}
|
|
31
|
-
/** Reads `--headless`, `--port <n>`, `--grep <text>` and `--file <spec>` (repeatable) from `argv` */
|
|
34
|
+
/** Reads `--headless`, `--port <n>`, `--grep <text>`, `--group <name>` and `--file <spec>` (repeatable) from `argv` */
|
|
32
35
|
export function parseRunnerFlags(argv) {
|
|
33
36
|
const values = parseFlags(argv);
|
|
34
37
|
const flags = {};
|
|
@@ -43,6 +46,8 @@ export function parseRunnerFlags(argv) {
|
|
|
43
46
|
}
|
|
44
47
|
if (values.grep !== undefined)
|
|
45
48
|
flags.grep = values.grep;
|
|
49
|
+
if (values.group !== undefined)
|
|
50
|
+
flags.group = values.group;
|
|
46
51
|
if (values.file?.length)
|
|
47
52
|
flags.files = values.file;
|
|
48
53
|
return flags;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type FlowFile } from "../flows/core/index.js";
|
|
2
|
+
import { type FlowEvent } from "../flows/run/index.js";
|
|
3
|
+
export interface FlowReporterOptions {
|
|
4
|
+
flow: FlowFile;
|
|
5
|
+
write: (text: string) => void;
|
|
6
|
+
/** Colour with ANSI codes */
|
|
7
|
+
color: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Prints a flow run for a person: one line per node as it ends, then a count. Start and End are not listed. */
|
|
10
|
+
export declare class FlowReporter {
|
|
11
|
+
private readonly write;
|
|
12
|
+
private readonly paint;
|
|
13
|
+
private readonly nodes;
|
|
14
|
+
private readonly flow;
|
|
15
|
+
private started;
|
|
16
|
+
constructor({ flow, write, color }: FlowReporterOptions);
|
|
17
|
+
private shapeOf;
|
|
18
|
+
private listed;
|
|
19
|
+
private nameOf;
|
|
20
|
+
handle(event: FlowEvent): void;
|
|
21
|
+
private line;
|
|
22
|
+
private summary;
|
|
23
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { nodeDef, } from "../flows/core/index.js";
|
|
2
|
+
import { maskSecrets as maskText, } from "../flows/run/index.js";
|
|
3
|
+
import { STEP_NODES } from "../flows/steps/index.js";
|
|
4
|
+
const took = (ms) => ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
5
|
+
const SKIPPED_BY_SHAPE = new Set(["start", "end"]);
|
|
6
|
+
/** Prints a flow run for a person: one line per node as it ends, then a count. Start and End are not listed. */
|
|
7
|
+
export class FlowReporter {
|
|
8
|
+
constructor({ flow, write, color }) {
|
|
9
|
+
this.started = Date.now();
|
|
10
|
+
this.flow = flow;
|
|
11
|
+
this.write = write;
|
|
12
|
+
this.paint = (code, text) => color ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
13
|
+
this.nodes = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
14
|
+
}
|
|
15
|
+
shapeOf(node) {
|
|
16
|
+
try {
|
|
17
|
+
return nodeDef(node.kind).shape;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
listed(node) {
|
|
24
|
+
return !!node && !SKIPPED_BY_SHAPE.has(this.shapeOf(node) ?? "");
|
|
25
|
+
}
|
|
26
|
+
nameOf(node, result) {
|
|
27
|
+
let label;
|
|
28
|
+
try {
|
|
29
|
+
label = nodeDef(node.kind).label;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
label = STEP_NODES.find((d) => d.kind === node.kind)?.label ?? node.kind;
|
|
33
|
+
}
|
|
34
|
+
const base = node.title || label;
|
|
35
|
+
const wrote = result.wrote ? Object.keys(result.wrote) : [];
|
|
36
|
+
return node.title || wrote.length === 0
|
|
37
|
+
? base
|
|
38
|
+
: `${base} ${wrote.map((n) => `vars.${n}`).join(", ")}`;
|
|
39
|
+
}
|
|
40
|
+
handle(event) {
|
|
41
|
+
if (event.type === "flow-start") {
|
|
42
|
+
this.started = Date.now();
|
|
43
|
+
this.write(`Flow "${this.flow.name}" against ${event.environment}\n`);
|
|
44
|
+
}
|
|
45
|
+
else if (event.type === "node-end") {
|
|
46
|
+
const node = this.nodes.get(event.nodeId);
|
|
47
|
+
if (this.listed(node))
|
|
48
|
+
this.line(node, event.result);
|
|
49
|
+
}
|
|
50
|
+
else if (event.type === "flow-end") {
|
|
51
|
+
this.summary(event.summary);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
line(node, result) {
|
|
55
|
+
const name = this.nameOf(node, result);
|
|
56
|
+
const time = result.durationMs === undefined
|
|
57
|
+
? ""
|
|
58
|
+
: ` ${this.paint(90, `(${took(result.durationMs)})`)}`;
|
|
59
|
+
if (result.status === "failed") {
|
|
60
|
+
this.write(` ${this.paint(31, "✖")} ${name}${time}\n`);
|
|
61
|
+
if (result.error)
|
|
62
|
+
this.write(`${maskText(result.error)
|
|
63
|
+
.split("\n")
|
|
64
|
+
.map((l) => ` ${l}`)
|
|
65
|
+
.join("\n")}\n`);
|
|
66
|
+
}
|
|
67
|
+
else if (result.status === "skipped") {
|
|
68
|
+
this.write(` ${this.paint(33, "⊘")} ${name} ${this.paint(90, `(skipped${result.note ? `: ${result.note}` : ""})`)}\n`);
|
|
69
|
+
}
|
|
70
|
+
else if (result.branch !== undefined) {
|
|
71
|
+
this.write(` ${this.paint(36, "◇")} ${name} → ${result.branch}${time}\n`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
this.write(` ${this.paint(32, "✔")} ${name}${time}\n`);
|
|
75
|
+
if (result.note)
|
|
76
|
+
this.write(` ${this.paint(90, result.note)}\n`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
summary(summary) {
|
|
80
|
+
const counts = { failed: 0, passed: 0, skipped: 0, "not-run": 0 };
|
|
81
|
+
for (const [id, result] of Object.entries(summary.nodes)) {
|
|
82
|
+
if (this.listed(this.nodes.get(id)))
|
|
83
|
+
counts[result.status]++;
|
|
84
|
+
}
|
|
85
|
+
if (summary.error)
|
|
86
|
+
this.write(`\n${this.paint(31, maskText(summary.error))}\n`);
|
|
87
|
+
if (summary.status === "cancelled")
|
|
88
|
+
this.write(`\nCancelled\n`);
|
|
89
|
+
const parts = [
|
|
90
|
+
counts.failed > 0 && this.paint(31, `${counts.failed} failed`),
|
|
91
|
+
this.paint(32, `${counts.passed} passed`),
|
|
92
|
+
counts.skipped > 0 && this.paint(33, `${counts.skipped} skipped`),
|
|
93
|
+
counts["not-run"] > 0 && `${counts["not-run"]} not run`,
|
|
94
|
+
].filter(Boolean);
|
|
95
|
+
const total = Date.now() - this.started;
|
|
96
|
+
this.write(`\n${parts.join(", ")} ${this.paint(90, `(${took(total)})`)}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Io } from "./main.js";
|
|
2
|
+
export declare const FLOW_USAGE = "Usage:\n samurai flow list [--json] List the flows in ./flows\n samurai flow check [<flow>...] [options] Check flows without running them (all flows when none is given)\n samurai flow run <flow>... [options] Run flows, in the order given\n samurai flow run --all [options] Run every flow in ./flows, in file name order\n\nA flow is an id (flows/<id>.flow.json) or a path to a .flow.json file.\n\nOptions for check and run:\n --env <name> Environment to use (or SAMURAI_ENV)\n --allow-unsupported Skip nodes that can't run yet (HTTP request, ...) instead of refusing the flow\n --json Machine-readable output: JSON lines for run, one array for check and list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one\n --port <n> Browser debugging port (default 9223)\n\nEach run writes result/flows/<id>.json.\nExit code: 0 when every node that should run passed, 1 when a node failed or the run was cancelled,\n2 for a usage, configuration or flow-file error, or a flow that has errors (nothing ran).\n";
|
|
3
|
+
/** `samurai flow ...`: `argv` is what follows `flow`. Returns the exit code */
|
|
4
|
+
export declare function flowCommand(argv: string[], io: Io): Promise<number>;
|
package/dist/cli/flow.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parseCommandLine, parseRunnerFlags, parseRunOverrides, } from "./args.js";
|
|
3
|
+
import { FlowReporter } from "./flow-reporter.js";
|
|
4
|
+
import {} from "../flows/core/index.js";
|
|
5
|
+
import { FLOW_SUFFIX, FlowCheckError, FlowReportBuilder, checkFlowInProject, flowsDir, listFlows, readFlow, readFlowPath, runFlowInProject, writeFlowReport, } from "../flows/run/index.js";
|
|
6
|
+
export const FLOW_USAGE = `Usage:
|
|
7
|
+
samurai flow list [--json] List the flows in ./flows
|
|
8
|
+
samurai flow check [<flow>...] [options] Check flows without running them (all flows when none is given)
|
|
9
|
+
samurai flow run <flow>... [options] Run flows, in the order given
|
|
10
|
+
samurai flow run --all [options] Run every flow in ./flows, in file name order
|
|
11
|
+
|
|
12
|
+
A flow is an id (flows/<id>.flow.json) or a path to a .flow.json file.
|
|
13
|
+
|
|
14
|
+
Options for check and run:
|
|
15
|
+
--env <name> Environment to use (or SAMURAI_ENV)
|
|
16
|
+
--allow-unsupported Skip nodes that can't run yet (HTTP request, ...) instead of refusing the flow
|
|
17
|
+
--json Machine-readable output: JSON lines for run, one array for check and list
|
|
18
|
+
|
|
19
|
+
Options for run:
|
|
20
|
+
--timeout <ms> Time one test may take
|
|
21
|
+
--expect-timeout <ms> Time actions and assertions retry
|
|
22
|
+
--headless, --no-headless Run the browser without a window, or with one
|
|
23
|
+
--port <n> Browser debugging port (default 9223)
|
|
24
|
+
|
|
25
|
+
Each run writes result/flows/<id>.json.
|
|
26
|
+
Exit code: 0 when every node that should run passed, 1 when a node failed or the run was cancelled,
|
|
27
|
+
2 for a usage, configuration or flow-file error, or a flow that has errors (nothing ran).
|
|
28
|
+
`;
|
|
29
|
+
class UsageError extends Error {
|
|
30
|
+
}
|
|
31
|
+
const describe = (flow, p) => {
|
|
32
|
+
const node = p.nodeId ? flow.nodes.find((n) => n.id === p.nodeId) : undefined;
|
|
33
|
+
return {
|
|
34
|
+
...p,
|
|
35
|
+
flow: flow.id,
|
|
36
|
+
...(node?.key && { key: node.key }),
|
|
37
|
+
...(node?.title && { title: node.title }),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
function paintWith(io) {
|
|
41
|
+
return (code, text) => io.color ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
42
|
+
}
|
|
43
|
+
function problemLines(io, problems) {
|
|
44
|
+
const paint = paintWith(io);
|
|
45
|
+
return problems
|
|
46
|
+
.map((p) => {
|
|
47
|
+
const who = p.key
|
|
48
|
+
? `${p.key}${p.title && p.title !== p.key ? ` (${p.title})` : ""}: `
|
|
49
|
+
: "";
|
|
50
|
+
const mark = p.level === "error" ? paint(31, "✖ error ") : paint(33, "! warning");
|
|
51
|
+
return ` ${mark} ${who}${p.message}\n`;
|
|
52
|
+
})
|
|
53
|
+
.join("");
|
|
54
|
+
}
|
|
55
|
+
/** A flow is an id in ./flows, or a path to a .flow.json file */
|
|
56
|
+
async function load(projectDir, ref) {
|
|
57
|
+
const isPath = /[\\/]/.test(ref) || ref.endsWith(".json");
|
|
58
|
+
return isPath
|
|
59
|
+
? readFlowPath(path.resolve(projectDir, ref))
|
|
60
|
+
: readFlow(projectDir, ref);
|
|
61
|
+
}
|
|
62
|
+
async function everyFlow(projectDir) {
|
|
63
|
+
const ids = await listFlows(projectDir);
|
|
64
|
+
if (ids.length === 0)
|
|
65
|
+
throw new Error(`No flows: ${flowsDir(projectDir)} has no *${FLOW_SUFFIX} files.`);
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
async function list(io, json) {
|
|
69
|
+
const projectDir = process.cwd();
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (const id of await listFlows(projectDir)) {
|
|
72
|
+
try {
|
|
73
|
+
const flow = await readFlow(projectDir, id);
|
|
74
|
+
rows.push({ id, name: flow.name, nodes: flow.nodes.length });
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
rows.push({ id, error: e.message });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (json)
|
|
81
|
+
io.out(`${JSON.stringify(rows, null, 2)}\n`);
|
|
82
|
+
else if (rows.length === 0)
|
|
83
|
+
io.out("No flows in ./flows\n");
|
|
84
|
+
else
|
|
85
|
+
for (const row of rows)
|
|
86
|
+
io.out(row.error
|
|
87
|
+
? `${row.id} (unreadable: ${row.error})\n`
|
|
88
|
+
: `${row.id} ${row.name} (${row.nodes} ${row.nodes === 1 ? "node" : "nodes"})\n`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
async function check(refs, io, options) {
|
|
92
|
+
const projectDir = process.cwd();
|
|
93
|
+
const targets = refs.length ? refs : await everyFlow(projectDir);
|
|
94
|
+
const all = [];
|
|
95
|
+
let code = 0;
|
|
96
|
+
for (const ref of targets) {
|
|
97
|
+
let flow;
|
|
98
|
+
try {
|
|
99
|
+
flow = await load(projectDir, ref);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
io.err(`${e.message}\n`);
|
|
103
|
+
code = 2;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const result = await checkFlowInProject({
|
|
107
|
+
projectDir,
|
|
108
|
+
flow,
|
|
109
|
+
unsupported: options.unsupported,
|
|
110
|
+
...(options.environment && { environment: options.environment }),
|
|
111
|
+
});
|
|
112
|
+
const problems = result.problems.map((p) => describe(result.flow, p));
|
|
113
|
+
all.push(...problems);
|
|
114
|
+
if (code === 0 && problems.some((p) => p.level === "error"))
|
|
115
|
+
code = 1;
|
|
116
|
+
if (!options.json) {
|
|
117
|
+
io.out(problems.length === 0
|
|
118
|
+
? `${flow.id}: ok\n`
|
|
119
|
+
: `${flow.id}:\n${problemLines(io, problems)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (options.json)
|
|
123
|
+
io.out(`${JSON.stringify(all, null, 2)}\n`);
|
|
124
|
+
return code;
|
|
125
|
+
}
|
|
126
|
+
async function run(refs, io, options) {
|
|
127
|
+
const projectDir = process.cwd();
|
|
128
|
+
const flows = [];
|
|
129
|
+
for (const ref of refs)
|
|
130
|
+
flows.push(await load(projectDir, ref));
|
|
131
|
+
const refuse = (problems, flow) => {
|
|
132
|
+
const described = problems.map((p) => describe(flow, p));
|
|
133
|
+
if (options.json)
|
|
134
|
+
io.out(`${JSON.stringify(described, null, 2)}\n`);
|
|
135
|
+
else
|
|
136
|
+
io.err(`${flow.id} can't run:\n${problemLines(io, described)}Nothing was run.\n`);
|
|
137
|
+
return 2;
|
|
138
|
+
};
|
|
139
|
+
// Several flows: refuse them all before the first one runs
|
|
140
|
+
if (flows.length > 1) {
|
|
141
|
+
for (const flow of flows) {
|
|
142
|
+
const { problems } = await checkFlowInProject({
|
|
143
|
+
projectDir,
|
|
144
|
+
flow,
|
|
145
|
+
unsupported: options.unsupported,
|
|
146
|
+
...(options.environment && { environment: options.environment }),
|
|
147
|
+
});
|
|
148
|
+
const errors = problems.filter((p) => p.level === "error");
|
|
149
|
+
if (errors.length)
|
|
150
|
+
return refuse(errors, flow);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
let code = 0;
|
|
154
|
+
for (const flow of flows) {
|
|
155
|
+
if (io.signal?.aborted) {
|
|
156
|
+
code = 1;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
const builder = new FlowReportBuilder(flow);
|
|
160
|
+
const human = options.json
|
|
161
|
+
? undefined
|
|
162
|
+
: new FlowReporter({ flow, write: io.out, color: io.color });
|
|
163
|
+
if (flows.indexOf(flow) > 0 && !options.json)
|
|
164
|
+
io.out("\n");
|
|
165
|
+
let summary;
|
|
166
|
+
try {
|
|
167
|
+
summary = await runFlowInProject({
|
|
168
|
+
...options.run,
|
|
169
|
+
projectDir,
|
|
170
|
+
flow,
|
|
171
|
+
unsupported: options.unsupported,
|
|
172
|
+
...(io.signal && { signal: io.signal }),
|
|
173
|
+
onEvent: (event) => {
|
|
174
|
+
builder.handle(event);
|
|
175
|
+
if (options.json)
|
|
176
|
+
io.out(`${JSON.stringify(event)}\n`);
|
|
177
|
+
else
|
|
178
|
+
human?.handle(event);
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
if (e instanceof FlowCheckError)
|
|
184
|
+
return refuse(e.problems, flow);
|
|
185
|
+
throw e;
|
|
186
|
+
}
|
|
187
|
+
const file = await writeFlowReport(projectDir, builder.finish(summary));
|
|
188
|
+
if (!options.json)
|
|
189
|
+
io.out(`Report: ${path.relative(projectDir, file) || file}\n`);
|
|
190
|
+
if (summary.status !== "passed")
|
|
191
|
+
code = 1;
|
|
192
|
+
}
|
|
193
|
+
return code;
|
|
194
|
+
}
|
|
195
|
+
/** `samurai flow ...`: `argv` is what follows `flow`. Returns the exit code */
|
|
196
|
+
export async function flowCommand(argv, io) {
|
|
197
|
+
const { values, positionals } = parseCommandLine(argv);
|
|
198
|
+
const [sub, ...refs] = positionals;
|
|
199
|
+
const unsupported = values["allow-unsupported"] ? "skip" : "fail";
|
|
200
|
+
const json = values.json === true;
|
|
201
|
+
const overrides = parseRunOverrides(argv, io.env);
|
|
202
|
+
try {
|
|
203
|
+
if (sub === "list" && refs.length === 0)
|
|
204
|
+
return await list(io, json);
|
|
205
|
+
if (sub === "check")
|
|
206
|
+
return await check(refs, io, {
|
|
207
|
+
json,
|
|
208
|
+
unsupported,
|
|
209
|
+
...(overrides.environment && { environment: overrides.environment }),
|
|
210
|
+
});
|
|
211
|
+
if (sub === "run") {
|
|
212
|
+
if (values.all && refs.length > 0)
|
|
213
|
+
throw new UsageError("Give flows or --all, not both.");
|
|
214
|
+
if (!values.all && refs.length === 0)
|
|
215
|
+
throw new UsageError("Which flow? Give a flow or --all.");
|
|
216
|
+
const flags = parseRunnerFlags(argv);
|
|
217
|
+
const targets = values.all ? await everyFlow(process.cwd()) : refs;
|
|
218
|
+
return await run(targets, io, {
|
|
219
|
+
json,
|
|
220
|
+
unsupported,
|
|
221
|
+
...(overrides.environment && { environment: overrides.environment }),
|
|
222
|
+
run: {
|
|
223
|
+
...overrides,
|
|
224
|
+
...(flags.headless !== undefined && { headless: flags.headless }),
|
|
225
|
+
...(flags.port !== undefined && { port: flags.port }),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
throw new UsageError(sub === undefined
|
|
230
|
+
? "Which flow command?"
|
|
231
|
+
: `Unknown flow command "${[sub, ...refs].join(" ")}"`);
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
if (e instanceof UsageError) {
|
|
235
|
+
io.err(`${e.message}\n\n${FLOW_USAGE}`);
|
|
236
|
+
return 2;
|
|
237
|
+
}
|
|
238
|
+
throw e;
|
|
239
|
+
}
|
|
240
|
+
}
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\nRun it in the project folder, the one holding samurai.config.ts.\n";
|
|
1
|
+
export declare const USAGE = "samurai: browser tests over WebDriver BiDi\n\nUsage:\n samurai run [options] Run the tests (the default command)\n samurai list [options] List the tests without running them\n samurai record <spec> Open a browser and record what you do into a spec file\n samurai init [folder] Scaffold a project: config, an example test, .env.example\n samurai flow <command> List, check and run the flows in ./flows (see below)\n\nOptions for run and list:\n --env <name> Environment to run against (or SAMURAI_ENV)\n --file <spec> Only this spec file; repeatable\n --grep <text> Only tests whose full name contains the text\n --group <name> Only the tests of this group of the config's groups\n --json Machine-readable output: JSON lines for run, one array for list\n\nOptions for run:\n --timeout <ms> Time one test may take\n --expect-timeout <ms> Time actions and assertions retry\n --headless, --no-headless Run the browser without a window, or with one.\n Default: the config's use.headless, else headless when CI is set\n --port <n> Browser debugging port (default 9223)\n\nOptions for record (also --env, --port, --json):\n --new <title> Add a test with this title (creates the spec file if needed)\n --test <name|index> Record into this test; not needed when the file has one\n --at <n> Insert from step n (default: after the last step)\n --url <url> Page to start on (default: the environment's baseURL)\n Stop with Ctrl+C or by closing the browser window.\n\n -h, --help Show this help\n -v, --version Show the version\n\nExit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.\n\nsamurai flow:\n list The flows in ./flows\n check [<flow>...] Check flows without running them\n run <flow>... | --all Run flows; each writes result/flows/<id>.json\n Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported\n Run `samurai flow` alone for the details.\n\nRun it in the project folder, the one holding samurai.config.ts.\n";
|
|
2
2
|
export interface Io {
|
|
3
3
|
out: (text: string) => void;
|
|
4
4
|
err: (text: string) => void;
|
package/dist/cli/main.js
CHANGED
|
@@ -4,6 +4,7 @@ import { recordSpec } from "../recorder/index.js";
|
|
|
4
4
|
import { listTests, runTests } from "../runner/run.js";
|
|
5
5
|
import { stepToSource } from "../steps/index.js";
|
|
6
6
|
import { ConsoleReporter } from "./console-reporter.js";
|
|
7
|
+
import { flowCommand } from "./flow.js";
|
|
7
8
|
import { initProject } from "./init.js";
|
|
8
9
|
export const USAGE = `samurai: browser tests over WebDriver BiDi
|
|
9
10
|
|
|
@@ -12,11 +13,13 @@ Usage:
|
|
|
12
13
|
samurai list [options] List the tests without running them
|
|
13
14
|
samurai record <spec> Open a browser and record what you do into a spec file
|
|
14
15
|
samurai init [folder] Scaffold a project: config, an example test, .env.example
|
|
16
|
+
samurai flow <command> List, check and run the flows in ./flows (see below)
|
|
15
17
|
|
|
16
18
|
Options for run and list:
|
|
17
19
|
--env <name> Environment to run against (or SAMURAI_ENV)
|
|
18
20
|
--file <spec> Only this spec file; repeatable
|
|
19
21
|
--grep <text> Only tests whose full name contains the text
|
|
22
|
+
--group <name> Only the tests of this group of the config's groups
|
|
20
23
|
--json Machine-readable output: JSON lines for run, one array for list
|
|
21
24
|
|
|
22
25
|
Options for run:
|
|
@@ -37,6 +40,14 @@ Options for record (also --env, --port, --json):
|
|
|
37
40
|
-v, --version Show the version
|
|
38
41
|
|
|
39
42
|
Exit code: 0 when every test passed, 1 when a test failed, 2 for a usage or configuration error.
|
|
43
|
+
|
|
44
|
+
samurai flow:
|
|
45
|
+
list The flows in ./flows
|
|
46
|
+
check [<flow>...] Check flows without running them
|
|
47
|
+
run <flow>... | --all Run flows; each writes result/flows/<id>.json
|
|
48
|
+
Options for run: --env, --headless, --timeout, --expect-timeout, --port, --json, --allow-unsupported
|
|
49
|
+
Run \`samurai flow\` alone for the details.
|
|
50
|
+
|
|
40
51
|
Run it in the project folder, the one holding samurai.config.ts.
|
|
41
52
|
`;
|
|
42
53
|
function version() {
|
|
@@ -59,6 +70,8 @@ export async function main(argv, io) {
|
|
|
59
70
|
io.out(`${version()}\n`);
|
|
60
71
|
return 0;
|
|
61
72
|
}
|
|
73
|
+
if (command === "flow")
|
|
74
|
+
return await flowCommand(rest, io);
|
|
62
75
|
if (command === "init") {
|
|
63
76
|
const { written, skipped } = initProject(positionals[0] ?? ".");
|
|
64
77
|
for (const file of written)
|
|
@@ -124,6 +137,7 @@ export async function main(argv, io) {
|
|
|
124
137
|
...(flags.headless !== undefined && { headless: flags.headless }),
|
|
125
138
|
...(flags.port !== undefined && { port: flags.port }),
|
|
126
139
|
...(flags.grep !== undefined && { grep: flags.grep }),
|
|
140
|
+
...(flags.group !== undefined && { group: flags.group }),
|
|
127
141
|
...(flags.files && { files: flags.files }),
|
|
128
142
|
};
|
|
129
143
|
if (command === "list") {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type FlowProblem } from "./nodes/index.js";
|
|
2
|
+
import type { AnyNodeDefinition } from "./nodes/define.js";
|
|
3
|
+
import type { FlowFile, FlowNodeModel } from "./schema.js";
|
|
4
|
+
export type { FlowProblem } from "./nodes/index.js";
|
|
5
|
+
export interface CheckOptions {
|
|
6
|
+
/** The test ids that exist. When given, a Test node whose test is gone is an error. */
|
|
7
|
+
tests?: Iterable<string>;
|
|
8
|
+
/** The group names that exist. When given, a Group node whose group is gone is an error. */
|
|
9
|
+
groups?: Iterable<string>;
|
|
10
|
+
/** The environment expressions are checked against (its name and variable names). Without it, expressions are not checked, only left empty. */
|
|
11
|
+
environment?: {
|
|
12
|
+
name: string;
|
|
13
|
+
variables: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The kinds that have a handler. When given, every step or branches node of
|
|
17
|
+
* another kind is reported as unsupported:
|
|
18
|
+
* - `unsupported: "skip"` (default): a warning, `<label> nodes can't run yet. They are skipped.`
|
|
19
|
+
* - `unsupported: "fail"`: an error, `<label> can't run yet.`
|
|
20
|
+
* Without `handled` nothing is reported.
|
|
21
|
+
*/
|
|
22
|
+
handled?: ReadonlySet<string>;
|
|
23
|
+
unsupported?: "skip" | "fail";
|
|
24
|
+
/**
|
|
25
|
+
* How a node is named in every message (reachability, leads-to-End, branches,
|
|
26
|
+
* "needs at least N branches"). When it returns a string, that is the name; when it
|
|
27
|
+
* returns `undefined`, the node's title, else its key, else its kind's label is used.
|
|
28
|
+
* The SAMURAI app passes the editor's titles (a Test node shows its test's name).
|
|
29
|
+
*/
|
|
30
|
+
titles?: (node: FlowNodeModel) => string | undefined;
|
|
31
|
+
/** Definitions of kinds core doesn't have (steps). A node of a kind in neither is "Unknown step". */
|
|
32
|
+
nodes?: readonly AnyNodeDefinition[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Checks a flow before a run: the shape of the graph, keys, references that still
|
|
36
|
+
* exist, expressions, and what a run can't execute. Pure; returns every problem.
|
|
37
|
+
* Messages are the SAMURAI app's, except that a node is named by its `title`, else
|
|
38
|
+
* its key, else its kind's label (the app names it by what the editor shows).
|
|
39
|
+
*/
|
|
40
|
+
export declare function checkFlow(flow: FlowFile, options?: CheckOptions): FlowProblem[];
|
|
41
|
+
/** Every expression field that doesn't check out, as "Label: message" */
|
|
42
|
+
export declare function expressionProblems(flow: FlowFile, environment: {
|
|
43
|
+
name: string;
|
|
44
|
+
variables: Record<string, unknown>;
|
|
45
|
+
}, nodes?: readonly AnyNodeDefinition[]): FlowProblem[];
|