@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.
Files changed (101) hide show
  1. package/README.md +5 -0
  2. package/bin/samurai.mjs +7 -5
  3. package/dist/cli/args.d.ts +5 -1
  4. package/dist/cli/args.js +6 -1
  5. package/dist/cli/flow-reporter.d.ts +23 -0
  6. package/dist/cli/flow-reporter.js +98 -0
  7. package/dist/cli/flow.d.ts +4 -0
  8. package/dist/cli/flow.js +240 -0
  9. package/dist/cli/main.d.ts +1 -1
  10. package/dist/cli/main.js +14 -0
  11. package/dist/flows/core/check.d.ts +45 -0
  12. package/dist/flows/core/check.js +205 -0
  13. package/dist/flows/core/expression/check.d.ts +23 -0
  14. package/dist/flows/core/expression/check.js +95 -0
  15. package/dist/flows/core/expression/evaluate.d.ts +22 -0
  16. package/dist/flows/core/expression/evaluate.js +141 -0
  17. package/dist/flows/core/expression/help.d.ts +25 -0
  18. package/dist/flows/core/expression/help.js +75 -0
  19. package/dist/flows/core/expression/index.d.ts +5 -0
  20. package/dist/flows/core/expression/index.js +5 -0
  21. package/dist/flows/core/expression/parse.d.ts +61 -0
  22. package/dist/flows/core/expression/parse.js +317 -0
  23. package/dist/flows/core/expression/scope.d.ts +8 -0
  24. package/dist/flows/core/expression/scope.js +34 -0
  25. package/dist/flows/core/graph.d.ts +11 -0
  26. package/dist/flows/core/graph.js +65 -0
  27. package/dist/flows/core/index.d.ts +17 -0
  28. package/dist/flows/core/index.js +17 -0
  29. package/dist/flows/core/keys.d.ts +8 -0
  30. package/dist/flows/core/keys.js +46 -0
  31. package/dist/flows/core/nodes/condition.d.ts +7 -0
  32. package/dist/flows/core/nodes/condition.js +21 -0
  33. package/dist/flows/core/nodes/define.d.ts +132 -0
  34. package/dist/flows/core/nodes/define.js +21 -0
  35. package/dist/flows/core/nodes/end.d.ts +1 -0
  36. package/dist/flows/core/nodes/end.js +7 -0
  37. package/dist/flows/core/nodes/group.d.ts +5 -0
  38. package/dist/flows/core/nodes/group.js +18 -0
  39. package/dist/flows/core/nodes/index.d.ts +45 -0
  40. package/dist/flows/core/nodes/index.js +55 -0
  41. package/dist/flows/core/nodes/parallel.d.ts +1 -0
  42. package/dist/flows/core/nodes/parallel.js +11 -0
  43. package/dist/flows/core/nodes/path.d.ts +5 -0
  44. package/dist/flows/core/nodes/path.js +21 -0
  45. package/dist/flows/core/nodes/start.d.ts +1 -0
  46. package/dist/flows/core/nodes/start.js +7 -0
  47. package/dist/flows/core/nodes/test.d.ts +1 -0
  48. package/dist/flows/core/nodes/test.js +18 -0
  49. package/dist/flows/core/schema.d.ts +55 -0
  50. package/dist/flows/core/schema.js +33 -0
  51. package/dist/flows/core/steps.d.ts +42 -0
  52. package/dist/flows/core/steps.js +47 -0
  53. package/dist/flows/run/events.d.ts +45 -0
  54. package/dist/flows/run/events.js +1 -0
  55. package/dist/flows/run/files.d.ts +12 -0
  56. package/dist/flows/run/files.js +54 -0
  57. package/dist/flows/run/handlers/condition.d.ts +2 -0
  58. package/dist/flows/run/handlers/condition.js +17 -0
  59. package/dist/flows/run/handlers/end.d.ts +2 -0
  60. package/dist/flows/run/handlers/end.js +1 -0
  61. package/dist/flows/run/handlers/group.d.ts +2 -0
  62. package/dist/flows/run/handlers/group.js +7 -0
  63. package/dist/flows/run/handlers/index.d.ts +4 -0
  64. package/dist/flows/run/handlers/index.js +17 -0
  65. package/dist/flows/run/handlers/parallel.d.ts +3 -0
  66. package/dist/flows/run/handlers/parallel.js +2 -0
  67. package/dist/flows/run/handlers/start.d.ts +2 -0
  68. package/dist/flows/run/handlers/start.js +1 -0
  69. package/dist/flows/run/handlers/support.d.ts +29 -0
  70. package/dist/flows/run/handlers/support.js +22 -0
  71. package/dist/flows/run/handlers/test.d.ts +2 -0
  72. package/dist/flows/run/handlers/test.js +7 -0
  73. package/dist/flows/run/index.d.ts +8 -0
  74. package/dist/flows/run/index.js +7 -0
  75. package/dist/flows/run/project.d.ts +43 -0
  76. package/dist/flows/run/project.js +165 -0
  77. package/dist/flows/run/report.d.ts +39 -0
  78. package/dist/flows/run/report.js +46 -0
  79. package/dist/flows/run/run.d.ts +42 -0
  80. package/dist/flows/run/run.js +250 -0
  81. package/dist/flows/steps/api.d.ts +23 -0
  82. package/dist/flows/steps/api.js +40 -0
  83. package/dist/flows/steps/catalog.d.ts +16 -0
  84. package/dist/flows/steps/catalog.js +29 -0
  85. package/dist/flows/steps/define.d.ts +49 -0
  86. package/dist/flows/steps/define.js +40 -0
  87. package/dist/flows/steps/handler.d.ts +4 -0
  88. package/dist/flows/steps/handler.js +70 -0
  89. package/dist/flows/steps/index.d.ts +5 -0
  90. package/dist/flows/steps/index.js +4 -0
  91. package/dist/flows/steps/set-variable.d.ts +16 -0
  92. package/dist/flows/steps/set-variable.js +20 -0
  93. package/dist/flows/steps/wait.d.ts +8 -0
  94. package/dist/flows/steps/wait.js +14 -0
  95. package/dist/runner/groups.d.ts +39 -0
  96. package/dist/runner/groups.js +58 -0
  97. package/dist/runner/run.d.ts +4 -0
  98. package/dist/runner/run.js +12 -0
  99. package/dist/runner/test-runner.d.ts +12 -2
  100. package/dist/runner/test-runner.js +36 -6
  101. 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
- process.on("SIGINT", () => {
21
- if (interrupt.signal.aborted) process.exit(130);
22
- interrupt.abort();
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),
@@ -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>;
@@ -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
+ }
@@ -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[];