@itmetsam/samurai-framework 0.2.2 → 0.3.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 (111) 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 +97 -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 +42 -0
  12. package/dist/flows/core/check.js +202 -0
  13. package/dist/flows/core/expression/check.d.ts +21 -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 +7 -0
  24. package/dist/flows/core/expression/scope.js +25 -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 +16 -0
  28. package/dist/flows/core/index.js +16 -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/api.d.ts +9 -0
  32. package/dist/flows/core/nodes/api.js +12 -0
  33. package/dist/flows/core/nodes/condition.d.ts +7 -0
  34. package/dist/flows/core/nodes/condition.js +21 -0
  35. package/dist/flows/core/nodes/database.d.ts +9 -0
  36. package/dist/flows/core/nodes/database.js +12 -0
  37. package/dist/flows/core/nodes/define.d.ts +128 -0
  38. package/dist/flows/core/nodes/define.js +21 -0
  39. package/dist/flows/core/nodes/email.d.ts +9 -0
  40. package/dist/flows/core/nodes/email.js +12 -0
  41. package/dist/flows/core/nodes/end.d.ts +1 -0
  42. package/dist/flows/core/nodes/end.js +7 -0
  43. package/dist/flows/core/nodes/group.d.ts +5 -0
  44. package/dist/flows/core/nodes/group.js +18 -0
  45. package/dist/flows/core/nodes/index.d.ts +107 -0
  46. package/dist/flows/core/nodes/index.js +81 -0
  47. package/dist/flows/core/nodes/parallel.d.ts +1 -0
  48. package/dist/flows/core/nodes/parallel.js +11 -0
  49. package/dist/flows/core/nodes/path.d.ts +5 -0
  50. package/dist/flows/core/nodes/path.js +21 -0
  51. package/dist/flows/core/nodes/script.d.ts +9 -0
  52. package/dist/flows/core/nodes/script.js +12 -0
  53. package/dist/flows/core/nodes/set-variable.d.ts +13 -0
  54. package/dist/flows/core/nodes/set-variable.js +39 -0
  55. package/dist/flows/core/nodes/start.d.ts +1 -0
  56. package/dist/flows/core/nodes/start.js +7 -0
  57. package/dist/flows/core/nodes/test.d.ts +1 -0
  58. package/dist/flows/core/nodes/test.js +18 -0
  59. package/dist/flows/core/nodes/wait.d.ts +15 -0
  60. package/dist/flows/core/nodes/wait.js +18 -0
  61. package/dist/flows/core/schema.d.ts +55 -0
  62. package/dist/flows/core/schema.js +33 -0
  63. package/dist/flows/run/events.d.ts +43 -0
  64. package/dist/flows/run/events.js +1 -0
  65. package/dist/flows/run/files.d.ts +12 -0
  66. package/dist/flows/run/files.js +54 -0
  67. package/dist/flows/run/handlers/condition.d.ts +2 -0
  68. package/dist/flows/run/handlers/condition.js +17 -0
  69. package/dist/flows/run/handlers/end.d.ts +2 -0
  70. package/dist/flows/run/handlers/end.js +1 -0
  71. package/dist/flows/run/handlers/group.d.ts +2 -0
  72. package/dist/flows/run/handlers/group.js +7 -0
  73. package/dist/flows/run/handlers/index.d.ts +6 -0
  74. package/dist/flows/run/handlers/index.js +22 -0
  75. package/dist/flows/run/handlers/parallel.d.ts +3 -0
  76. package/dist/flows/run/handlers/parallel.js +2 -0
  77. package/dist/flows/run/handlers/set-variable.d.ts +2 -0
  78. package/dist/flows/run/handlers/set-variable.js +22 -0
  79. package/dist/flows/run/handlers/start.d.ts +2 -0
  80. package/dist/flows/run/handlers/start.js +1 -0
  81. package/dist/flows/run/handlers/support.d.ts +26 -0
  82. package/dist/flows/run/handlers/support.js +22 -0
  83. package/dist/flows/run/handlers/test.d.ts +2 -0
  84. package/dist/flows/run/handlers/test.js +7 -0
  85. package/dist/flows/run/handlers/wait.d.ts +2 -0
  86. package/dist/flows/run/handlers/wait.js +10 -0
  87. package/dist/flows/run/index.d.ts +8 -0
  88. package/dist/flows/run/index.js +7 -0
  89. package/dist/flows/run/project.d.ts +43 -0
  90. package/dist/flows/run/project.js +163 -0
  91. package/dist/flows/run/report.d.ts +39 -0
  92. package/dist/flows/run/report.js +46 -0
  93. package/dist/flows/run/run.d.ts +42 -0
  94. package/dist/flows/run/run.js +244 -0
  95. package/dist/locator/locator.d.ts +8 -0
  96. package/dist/locator/locator.js +46 -0
  97. package/dist/recorder/capture.d.ts +2 -0
  98. package/dist/recorder/capture.js +14 -0
  99. package/dist/recorder/normalise.d.ts +5 -0
  100. package/dist/recorder/normalise.js +12 -0
  101. package/dist/recorder/recorder.js +20 -18
  102. package/dist/runner/groups.d.ts +39 -0
  103. package/dist/runner/groups.js +58 -0
  104. package/dist/runner/run.d.ts +4 -0
  105. package/dist/runner/run.js +12 -0
  106. package/dist/runner/test-runner.d.ts +12 -2
  107. package/dist/runner/test-runner.js +36 -6
  108. package/dist/steps/emit.js +2 -0
  109. package/dist/steps/model.d.ts +6 -0
  110. package/dist/steps/parse.js +4 -0
  111. package/package.json +9 -1
@@ -0,0 +1,33 @@
1
+ /** The flow file format version this framework writes and reads */
2
+ export const FLOW_VERSION = 1;
3
+ /** Steps, in order, that lift an older file to FLOW_VERSION. Empty while version 1 is the only one. */
4
+ const MIGRATIONS = [];
5
+ /**
6
+ * Read a parsed flow file (JSON.parse output) into a FlowFile.
7
+ * A missing `version` is 1; older files are migrated; a file from a newer SAMURAI throws.
8
+ */
9
+ export function readFlowFile(raw) {
10
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
11
+ throw new Error("A flow file must be a JSON object.");
12
+ }
13
+ let flow = raw;
14
+ const version = flow.version === undefined ? 1 : flow.version;
15
+ if (typeof version !== "number" ||
16
+ !Number.isInteger(version) ||
17
+ version < 1) {
18
+ throw new Error("The flow file's version must be a positive whole number.");
19
+ }
20
+ if (version > FLOW_VERSION) {
21
+ throw new Error("This flow was saved by a newer SAMURAI. Update @itmetsam/samurai-framework.");
22
+ }
23
+ if (!Array.isArray(flow.nodes))
24
+ throw new Error("A flow file needs a nodes array.");
25
+ if (!Array.isArray(flow.edges))
26
+ throw new Error("A flow file needs an edges array.");
27
+ for (let at = version; at < FLOW_VERSION; at++) {
28
+ const step = MIGRATIONS.find((m) => m.from === at);
29
+ if (step)
30
+ flow = step.migrate(flow);
31
+ }
32
+ return { ...flow, version: FLOW_VERSION };
33
+ }
@@ -0,0 +1,43 @@
1
+ import type { RunEvent } from "../../runner/reporter.js";
2
+ export type NodeStatus = "passed" | "failed" | "skipped" | "not-run";
3
+ export interface NodeResult {
4
+ status: NodeStatus;
5
+ durationMs?: number;
6
+ error?: string;
7
+ /** Why it was skipped, e.g. "Not supported yet" */
8
+ note?: string;
9
+ /** Condition: the branch taken */
10
+ branch?: string;
11
+ /** Set variable: what it wrote */
12
+ wrote?: Record<string, unknown>;
13
+ }
14
+ /** One test of a node: absolute file and full name */
15
+ export interface NodeTest {
16
+ file: string;
17
+ name: string;
18
+ }
19
+ export type FlowEvent = {
20
+ type: "flow-start";
21
+ environment: string;
22
+ } | {
23
+ type: "node-start";
24
+ nodeId: string;
25
+ } | {
26
+ type: "node-end";
27
+ nodeId: string;
28
+ result: NodeResult;
29
+ } | {
30
+ type: "test";
31
+ nodeId: string;
32
+ event: RunEvent;
33
+ } | {
34
+ type: "flow-end";
35
+ summary: FlowSummary;
36
+ };
37
+ export interface FlowSummary {
38
+ status: "passed" | "failed" | "cancelled";
39
+ nodes: Record<string, NodeResult>;
40
+ vars: Record<string, unknown>;
41
+ /** The flow couldn't be walked at all */
42
+ error?: string;
43
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import { type FlowFile } from "../core/index.js";
2
+ export declare const FLOW_SUFFIX = ".flow.json";
3
+ /** The folder a project keeps its flows in */
4
+ export declare const flowsDir: (projectDir: string) => string;
5
+ /** The ids of the project's flows (file name without `.flow.json`), sorted. Other files are ignored; no folder means no flows. */
6
+ export declare function listFlows(projectDir: string): Promise<string[]>;
7
+ /** Reads `<project>/flows/<id>.flow.json`; the flow's `id` is the file name, whatever the file says */
8
+ export declare function readFlow(projectDir: string, id: string): Promise<FlowFile>;
9
+ /** Reads a `.flow.json` file by its path; the flow's `id` is `id`, else the file name without the suffix */
10
+ export declare function readFlowPath(file: string, id?: string): Promise<FlowFile>;
11
+ /** Reads every flow of the project, in id order; one that can't be read throws */
12
+ export declare function readFlows(projectDir: string): Promise<FlowFile[]>;
@@ -0,0 +1,54 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { readFlowFile } from "../core/index.js";
4
+ export const FLOW_SUFFIX = ".flow.json";
5
+ /** The folder a project keeps its flows in */
6
+ export const flowsDir = (projectDir) => path.join(path.resolve(projectDir), "flows");
7
+ /** The ids of the project's flows (file name without `.flow.json`), sorted. Other files are ignored; no folder means no flows. */
8
+ export async function listFlows(projectDir) {
9
+ let names;
10
+ try {
11
+ names = await readdir(flowsDir(projectDir));
12
+ }
13
+ catch (e) {
14
+ if (e.code === "ENOENT")
15
+ return [];
16
+ throw e;
17
+ }
18
+ return names
19
+ .filter((n) => n.endsWith(FLOW_SUFFIX) && n.length > FLOW_SUFFIX.length)
20
+ .map((n) => n.slice(0, -FLOW_SUFFIX.length))
21
+ .sort();
22
+ }
23
+ /** Reads `<project>/flows/<id>.flow.json`; the flow's `id` is the file name, whatever the file says */
24
+ export async function readFlow(projectDir, id) {
25
+ if (!id || /[\\/]/.test(id) || id.startsWith("."))
26
+ throw new Error(`"${id}" isn't a valid flow id.`);
27
+ const file = path.join(flowsDir(projectDir), `${id}${FLOW_SUFFIX}`);
28
+ return readFlowPath(file, id);
29
+ }
30
+ /** Reads a `.flow.json` file by its path; the flow's `id` is `id`, else the file name without the suffix */
31
+ export async function readFlowPath(file, id = path.basename(file).replace(/\.flow\.json$/, "")) {
32
+ let text;
33
+ try {
34
+ text = await readFile(file, "utf8");
35
+ }
36
+ catch (e) {
37
+ if (e.code === "ENOENT")
38
+ throw new Error(`No flow "${id}": ${file} doesn't exist.`);
39
+ throw new Error(`Couldn't read the flow "${id}" (${file}): ${e.message}`);
40
+ }
41
+ try {
42
+ return { ...readFlowFile(JSON.parse(text)), id };
43
+ }
44
+ catch (e) {
45
+ throw new Error(`Couldn't read the flow "${id}" (${file}): ${e.message}`);
46
+ }
47
+ }
48
+ /** Reads every flow of the project, in id order; one that can't be read throws */
49
+ export async function readFlows(projectDir) {
50
+ const flows = [];
51
+ for (const id of await listFlows(projectDir))
52
+ flows.push(await readFlow(projectDir, id));
53
+ return flows;
54
+ }
@@ -0,0 +1,2 @@
1
+ import { type Handler } from "./support.js";
2
+ export declare const condition: Handler;
@@ -0,0 +1,17 @@
1
+ import { evaluateText } from "../../core/index.js";
2
+ import { fail, problemAt } from "./support.js";
3
+ export const condition = async (node, run) => {
4
+ const text = String(node.config?.expr ?? "");
5
+ const problem = problemAt(node, run, text);
6
+ if (problem)
7
+ return { status: "failed", error: problem };
8
+ try {
9
+ return {
10
+ status: "passed",
11
+ branch: evaluateText(text, run.context) ? "true" : "false",
12
+ };
13
+ }
14
+ catch (e) {
15
+ return fail(e);
16
+ }
17
+ };
@@ -0,0 +1,2 @@
1
+ import type { Handler } from "./support.js";
2
+ export declare const end: Handler;
@@ -0,0 +1 @@
1
+ export const end = async () => ({ status: "passed" });
@@ -0,0 +1,2 @@
1
+ import { type Handler } from "./support.js";
2
+ export declare const group: Handler;
@@ -0,0 +1,7 @@
1
+ import { runNodeTests } from "./support.js";
2
+ export const group = async (node, run) => {
3
+ const list = run.nodeTests[node.id] ?? [];
4
+ if (list.length === 0)
5
+ return { status: "passed", note: "This group has no tests." };
6
+ return runNodeTests(node, run, list);
7
+ };
@@ -0,0 +1,6 @@
1
+ import type { Handler } from "./support.js";
2
+ export type { Handler, HandlerRun, Outcome } from "./support.js";
3
+ /** The kinds a run can execute, by kind */
4
+ export declare const HANDLERS: Readonly<Record<string, Handler>>;
5
+ /** Kinds that exist in `flows/core` and have no handler yet (core's one list) */
6
+ export declare const UNSUPPORTED: readonly string[];
@@ -0,0 +1,22 @@
1
+ import { UNSUPPORTED_KINDS } from "../../core/index.js";
2
+ import { condition } from "./condition.js";
3
+ import { end } from "./end.js";
4
+ import { group } from "./group.js";
5
+ import { parallel } from "./parallel.js";
6
+ import { setVariable } from "./set-variable.js";
7
+ import { start } from "./start.js";
8
+ import { test } from "./test.js";
9
+ import { wait } from "./wait.js";
10
+ /** The kinds a run can execute, by kind */
11
+ export const HANDLERS = {
12
+ start,
13
+ end,
14
+ test,
15
+ group,
16
+ condition,
17
+ parallel,
18
+ wait,
19
+ "set-variable": setVariable,
20
+ };
21
+ /** Kinds that exist in `flows/core` and have no handler yet (core's one list) */
22
+ export const UNSUPPORTED = UNSUPPORTED_KINDS;
@@ -0,0 +1,3 @@
1
+ import type { Handler } from "./support.js";
2
+ /** Its branches are walked by the walker; the node itself just opens them */
3
+ export declare const parallel: Handler;
@@ -0,0 +1,2 @@
1
+ /** Its branches are walked by the walker; the node itself just opens them */
2
+ export const parallel = async () => ({ status: "passed" });
@@ -0,0 +1,2 @@
1
+ import { type Handler } from "./support.js";
2
+ export declare const setVariable: Handler;
@@ -0,0 +1,22 @@
1
+ import { evaluateText, KEY_PATTERN } from "../../core/index.js";
2
+ import { fail, problemAt } from "./support.js";
3
+ export const setVariable = async (node, run) => {
4
+ const name = node.config?.name;
5
+ if (typeof name !== "string" || !KEY_PATTERN.test(name))
6
+ return {
7
+ status: "failed",
8
+ error: "Give the variable a name: lowercase letters, digits and _.",
9
+ };
10
+ const text = String(node.config?.value ?? "");
11
+ const problem = problemAt(node, run, text);
12
+ if (problem)
13
+ return { status: "failed", error: problem };
14
+ try {
15
+ const value = evaluateText(text, run.context);
16
+ run.context.vars[name] = value;
17
+ return { status: "passed", wrote: { [name]: value } };
18
+ }
19
+ catch (e) {
20
+ return fail(e);
21
+ }
22
+ };
@@ -0,0 +1,2 @@
1
+ import type { Handler } from "./support.js";
2
+ export declare const start: Handler;
@@ -0,0 +1 @@
1
+ export const start = async () => ({ status: "passed" });
@@ -0,0 +1,26 @@
1
+ import { type ExpressionContext, type FlowFile, type FlowNodeModel } from "../../core/index.js";
2
+ import type { RunEvent } from "../../../runner/reporter.js";
3
+ import type { NodeResult, NodeTest } from "../events.js";
4
+ /** What a handler can use of the run */
5
+ export interface HandlerRun {
6
+ flow: FlowFile;
7
+ environment: {
8
+ name: string;
9
+ variables: Record<string, unknown>;
10
+ };
11
+ context: ExpressionContext;
12
+ nodeTests: Record<string, NodeTest[]>;
13
+ signal: AbortSignal;
14
+ runTests: (tests: NodeTest[], onEvent: (e: RunEvent) => void) => Promise<"passed" | "failed" | "cancelled">;
15
+ sleep: (ms: number, signal: AbortSignal) => Promise<void>;
16
+ onTestEvent: (nodeId: string, e: RunEvent) => void;
17
+ }
18
+ /** What a handler decides; the walker adds timing */
19
+ export type Outcome = Omit<NodeResult, "durationMs"> | "cancelled";
20
+ /** Runs one kind of node. A `branches` node names the branch it takes in `branch` */
21
+ export type Handler = (node: FlowNodeModel, run: HandlerRun) => Promise<Outcome>;
22
+ export declare const fail: (e: unknown) => Outcome;
23
+ /** Runs `list` for the node and turns the result into an outcome */
24
+ export declare function runNodeTests(node: FlowNodeModel, run: HandlerRun, list: NodeTest[]): Promise<Outcome>;
25
+ /** The checker's problem with an expression, as the builder would show it: a bypassed check fails the node the same way */
26
+ export declare const problemAt: (node: FlowNodeModel, run: HandlerRun, text: string) => string | undefined;
@@ -0,0 +1,22 @@
1
+ import { checkExpression, parseExpression, scopeAt, } from "../../core/index.js";
2
+ export const fail = (e) => ({
3
+ status: "failed",
4
+ error: e.message,
5
+ });
6
+ /** Runs `list` for the node and turns the result into an outcome */
7
+ export async function runNodeTests(node, run, list) {
8
+ let result;
9
+ try {
10
+ result = await run.runTests(list, (e) => run.onTestEvent(node.id, e));
11
+ }
12
+ catch (e) {
13
+ return fail(e);
14
+ }
15
+ if (result === "cancelled")
16
+ return "cancelled";
17
+ return result === "passed"
18
+ ? { status: "passed" }
19
+ : { status: "failed", error: "A test failed." };
20
+ }
21
+ /** The checker's problem with an expression, as the builder would show it: a bypassed check fails the node the same way */
22
+ export const problemAt = (node, run, text) => checkExpression(parseExpression(text), scopeAt(run.flow, node.id, run.environment))?.message;
@@ -0,0 +1,2 @@
1
+ import { type Handler } from "./support.js";
2
+ export declare const test: Handler;
@@ -0,0 +1,7 @@
1
+ import { runNodeTests } from "./support.js";
2
+ export const test = async (node, run) => {
3
+ const list = run.nodeTests[node.id];
4
+ if (!list || list.length === 0)
5
+ return { status: "failed", error: "This test was deleted or moved." };
6
+ return runNodeTests(node, run, list);
7
+ };
@@ -0,0 +1,2 @@
1
+ import type { Handler } from "./support.js";
2
+ export declare const wait: Handler;
@@ -0,0 +1,10 @@
1
+ export const wait = async (node, run) => {
2
+ const ms = typeof node.config?.ms === "number" ? node.config.ms : 1000;
3
+ try {
4
+ await run.sleep(ms, run.signal);
5
+ }
6
+ catch {
7
+ return "cancelled";
8
+ }
9
+ return { status: "passed" };
10
+ };
@@ -0,0 +1,8 @@
1
+ /** Running flows: handlers, the walker, a project's flow files. Node only; see `flows/core` for the pure half. */
2
+ export * from "./events.js";
3
+ export { HANDLERS, UNSUPPORTED } from "./handlers/index.js";
4
+ export type { Handler, HandlerRun, Outcome } from "./handlers/index.js";
5
+ export { runFlow, type RunFlowOptions } from "./run.js";
6
+ export { FLOW_SUFFIX, flowsDir, listFlows, readFlow, readFlowPath, readFlows, } from "./files.js";
7
+ export * from "./report.js";
8
+ export { FlowCheckError, checkFlowInProject, chooseEnvironment, runFlowInProject, type RunFlowInProjectOptions, } from "./project.js";
@@ -0,0 +1,7 @@
1
+ /** Running flows: handlers, the walker, a project's flow files. Node only; see `flows/core` for the pure half. */
2
+ export * from "./events.js";
3
+ export { HANDLERS, UNSUPPORTED } from "./handlers/index.js";
4
+ export { runFlow } from "./run.js";
5
+ export { FLOW_SUFFIX, flowsDir, listFlows, readFlow, readFlowPath, readFlows, } from "./files.js";
6
+ export * from "./report.js";
7
+ export { FlowCheckError, checkFlowInProject, chooseEnvironment, runFlowInProject, } from "./project.js";
@@ -0,0 +1,43 @@
1
+ import { type FlowFile, type FlowProblem } from "../core/index.js";
2
+ import { type ProjectOptions } from "../../runner/project.js";
3
+ import { runTests } from "../../runner/run.js";
4
+ import type { SamuraiTestConfig } from "../../types/config.js";
5
+ import type { FlowEvent, FlowSummary } from "./events.js";
6
+ /** The flow has errors; nothing was run */
7
+ export declare class FlowCheckError extends Error {
8
+ readonly problems: FlowProblem[];
9
+ constructor(problems: FlowProblem[]);
10
+ }
11
+ export interface RunFlowInProjectOptions extends ProjectOptions {
12
+ /** The flow to run... */
13
+ flow?: FlowFile;
14
+ /** ...or the id of one in `<project>/flows` */
15
+ flowId?: string;
16
+ /** Beats the flow's `envDefault`, the config's `defaultEnvironment` and the first environment */
17
+ environment?: string;
18
+ signal?: AbortSignal;
19
+ onEvent?: (event: FlowEvent) => void;
20
+ /** `"fail"` (default): a flow with a node that can't run is refused. `"skip"`: those nodes are skipped. */
21
+ unsupported?: "fail" | "skip";
22
+ port?: number;
23
+ /** @internal Replaces the runner of each Test and Group node (tests) */
24
+ testRunner?: typeof runTests;
25
+ /** @internal */
26
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
27
+ }
28
+ /** The environment: the option, else the flow's `envDefault` (when the project has it), else the config's default, else the first, else "default" */
29
+ export declare function chooseEnvironment(config: SamuraiTestConfig, flow: FlowFile, option?: string): string;
30
+ /**
31
+ * Checks a flow against the project without running it: its tests, groups and environment
32
+ * variables, and which kinds can run. Returns the flow (with keys) and every problem, warnings included.
33
+ */
34
+ export declare function checkFlowInProject(options: Pick<RunFlowInProjectOptions, "flow" | "flowId" | "projectDir" | "config" | "dataDir" | "environment" | "unsupported">): Promise<{
35
+ flow: FlowFile;
36
+ problems: FlowProblem[];
37
+ }>;
38
+ /**
39
+ * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
40
+ * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
41
+ * its tests through `runTests`, one after the other. One at a time per process.
42
+ */
43
+ export declare function runFlowInProject(options: RunFlowInProjectOptions): Promise<FlowSummary>;
@@ -0,0 +1,163 @@
1
+ import path from "node:path";
2
+ import { loadConfig, setActiveProject } from "../../config/config.js";
3
+ import { checkFlow, withKeys, } from "../core/index.js";
4
+ import { DEFAULT_SRC_DIR, resolveGroups, testId, toPosix, } from "../../runner/groups.js";
5
+ import { withProject } from "../../runner/project.js";
6
+ import { runTests } from "../../runner/run.js";
7
+ import TestRunner from "../../runner/test-runner.js";
8
+ import { readFlow } from "./files.js";
9
+ import { HANDLERS } from "./handlers/index.js";
10
+ import { runFlow } from "./run.js";
11
+ /** The flow has errors; nothing was run */
12
+ export class FlowCheckError extends Error {
13
+ constructor(problems) {
14
+ super(`This flow can't run:\n${problems.map((p) => ` - ${p.message}`).join("\n")}`);
15
+ this.problems = problems;
16
+ this.name = "FlowCheckError";
17
+ }
18
+ }
19
+ /** Reads the project's config without keeping the project active (`withProject` is one at a time) */
20
+ async function configOf(options) {
21
+ if (options.config)
22
+ return options.config;
23
+ setActiveProject({ dir: path.resolve(options.projectDir ?? process.cwd()) });
24
+ try {
25
+ return await loadConfig();
26
+ }
27
+ finally {
28
+ setActiveProject(undefined);
29
+ }
30
+ }
31
+ /** The environment: the option, else the flow's `envDefault` (when the project has it), else the config's default, else the first, else "default" */
32
+ export function chooseEnvironment(config, flow, option) {
33
+ const names = Object.keys(config.environments ?? {});
34
+ if (option)
35
+ return option;
36
+ if (flow.envDefault && names.includes(flow.envDefault))
37
+ return flow.envDefault;
38
+ return config.defaultEnvironment ?? names[0] ?? "default";
39
+ }
40
+ /** What the project says about a flow: its environment, tests and groups */
41
+ async function prepare(options) {
42
+ const projectDir = path.resolve(options.projectDir ?? process.cwd());
43
+ if (!options.flow && !options.flowId)
44
+ throw new Error("Give a flow or a flowId.");
45
+ const flow = withKeys(options.flow ?? (await readFlow(projectDir, options.flowId)));
46
+ const unsupported = options.unsupported ?? "fail";
47
+ // Config, environment, tests and groups (one project session, then it ends)
48
+ const config = await configOf({ ...options, projectDir });
49
+ const name = chooseEnvironment(config, flow, options.environment);
50
+ const environment = {
51
+ name,
52
+ variables: {
53
+ ...(Object.prototype.hasOwnProperty.call(config.environments ?? {}, name)
54
+ ? config.environments[name].variables
55
+ : {}),
56
+ },
57
+ };
58
+ const srcDir = path.resolve(projectDir, config.srcDir ?? DEFAULT_SRC_DIR);
59
+ const { tests, groups } = await withProject({
60
+ projectDir,
61
+ config,
62
+ ...(options.dataDir !== undefined && { dataDir: options.dataDir }),
63
+ environment: name,
64
+ }, async (run) => {
65
+ const found = await (await TestRunner.init(run)).register();
66
+ const tests = found.map((t) => ({
67
+ id: testId(toPosix(path.relative(srcDir, t.file)), t.name),
68
+ file: t.file,
69
+ name: t.name,
70
+ relative: toPosix(path.relative(srcDir, t.file)),
71
+ }));
72
+ const groups = resolveGroups(config, tests.map((t) => ({ file: t.relative, name: t.name })), projectDir, srcDir);
73
+ return { tests, groups };
74
+ });
75
+ const problems = checkFlow(flow, {
76
+ tests: tests.map((t) => t.id),
77
+ groups: groups.map((g) => g.name),
78
+ environment,
79
+ handled: new Set(Object.keys(HANDLERS)),
80
+ unsupported,
81
+ });
82
+ return {
83
+ projectDir,
84
+ flow,
85
+ unsupported,
86
+ config,
87
+ name,
88
+ environment,
89
+ tests,
90
+ groups,
91
+ problems,
92
+ };
93
+ }
94
+ /**
95
+ * Checks a flow against the project without running it: its tests, groups and environment
96
+ * variables, and which kinds can run. Returns the flow (with keys) and every problem, warnings included.
97
+ */
98
+ export async function checkFlowInProject(options) {
99
+ const { flow, problems } = await prepare(options);
100
+ return { flow, problems };
101
+ }
102
+ /**
103
+ * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
104
+ * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
105
+ * its tests through `runTests`, one after the other. One at a time per process.
106
+ */
107
+ export async function runFlowInProject(options) {
108
+ const { projectDir, flow, unsupported, config, name, environment, tests, groups, problems, } = await prepare(options);
109
+ const errors = problems.filter((p) => p.level === "error");
110
+ if (errors.length)
111
+ throw new FlowCheckError(errors);
112
+ // Phase 3: walk it
113
+ const byId = new Map(tests.map((t) => [t.id, t]));
114
+ const nodeTests = {};
115
+ for (const node of flow.nodes) {
116
+ const ids = node.kind === "test"
117
+ ? [node.ref?.testId ?? ""]
118
+ : node.kind === "group"
119
+ ? (groups.find((g) => g.name === node.ref?.group)?.testIds ?? [])
120
+ : undefined;
121
+ if (!ids)
122
+ continue;
123
+ nodeTests[node.id] = ids.flatMap((id) => {
124
+ const t = byId.get(id);
125
+ return t ? [{ file: t.file, name: t.name }] : [];
126
+ });
127
+ }
128
+ const run = options.testRunner ?? runTests;
129
+ return runFlow({
130
+ flow,
131
+ nodeTests,
132
+ environment,
133
+ ...(options.signal && { signal: options.signal }),
134
+ ...(options.onEvent && { onEvent: options.onEvent }),
135
+ ...(options.sleep && { sleep: options.sleep }),
136
+ unsupported,
137
+ runTests: async ({ tests, onEvent, signal }) => {
138
+ const summary = await run({
139
+ projectDir,
140
+ config,
141
+ ...(options.dataDir !== undefined && { dataDir: options.dataDir }),
142
+ // The environment the expressions see, whenever the project defines it
143
+ ...(Object.prototype.hasOwnProperty.call(config.environments ?? {}, name)
144
+ ? { environment: name }
145
+ : {}),
146
+ ...(options.headless !== undefined && { headless: options.headless }),
147
+ ...(options.timeout !== undefined && { timeout: options.timeout }),
148
+ ...(options.expectTimeout !== undefined && {
149
+ expectTimeout: options.expectTimeout,
150
+ }),
151
+ ...(options.port !== undefined && { port: options.port }),
152
+ files: [...new Set(tests.map((t) => t.file))],
153
+ testNames: tests.map((t) => t.name),
154
+ reportPath: false,
155
+ signal,
156
+ onEvent,
157
+ });
158
+ if (signal.aborted)
159
+ return "cancelled";
160
+ return summary.status === "success" ? "passed" : "failed";
161
+ },
162
+ });
163
+ }
@@ -0,0 +1,39 @@
1
+ import type { TestResult } from "../../types/test.js";
2
+ import type { FlowEvent, FlowSummary, NodeResult } from "./events.js";
3
+ /** One test a Test or Group node ran, as the runner reported it */
4
+ export type FlowTestResult = {
5
+ nodeId: string;
6
+ } & TestResult;
7
+ /** `result/flows/<id>.json`: what a flow run did, for a CI log or an archive */
8
+ export interface FlowReport {
9
+ flow: string;
10
+ name: string;
11
+ environment: string;
12
+ status: FlowSummary["status"];
13
+ startedAt: string;
14
+ durationMs: number;
15
+ nodes: Record<string, NodeResult>;
16
+ vars: Record<string, unknown>;
17
+ tests: FlowTestResult[];
18
+ /** The flow couldn't be walked at all */
19
+ error?: string;
20
+ }
21
+ /** Follows a run's events and builds its report once it ends (or `finish` is given what is known) */
22
+ export declare class FlowReportBuilder {
23
+ private readonly flow;
24
+ private environment;
25
+ private readonly tests;
26
+ private readonly started;
27
+ private readonly startedAt;
28
+ constructor(flow: {
29
+ id: string;
30
+ name: string;
31
+ });
32
+ handle(event: FlowEvent): void;
33
+ private collect;
34
+ finish(summary: FlowSummary, environment?: string): FlowReport;
35
+ }
36
+ /** Writes `<projectDir>/result/flows/<id>.json` with the registered secrets masked, as `report.json` is. Returns the path */
37
+ export declare function writeFlowReport(projectDir: string, report: FlowReport): Promise<string>;
38
+ /** Hides the registered secrets in a line of output (a node's error) */
39
+ export declare const maskSecrets: (text: string) => string;