@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
|
@@ -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,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 @@
|
|
|
1
|
+
export const end = async () => ({ status: "passed" });
|
|
@@ -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,17 @@
|
|
|
1
|
+
import { STEP_HANDLERS } from "../../steps/catalog.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 { start } from "./start.js";
|
|
7
|
+
import { test } from "./test.js";
|
|
8
|
+
/** The kinds a run can execute, by kind: the closed ones and every built-in step */
|
|
9
|
+
export const HANDLERS = {
|
|
10
|
+
start,
|
|
11
|
+
end,
|
|
12
|
+
test,
|
|
13
|
+
group,
|
|
14
|
+
condition,
|
|
15
|
+
parallel,
|
|
16
|
+
...STEP_HANDLERS,
|
|
17
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const start = async () => ({ status: "passed" });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type AnyNodeDefinition, 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
|
+
/** Definitions of kinds core doesn't have (steps), for expression scopes */
|
|
18
|
+
nodes: readonly AnyNodeDefinition[];
|
|
19
|
+
log: (nodeId: string, level: "info" | "warn" | "error", text: string) => void;
|
|
20
|
+
}
|
|
21
|
+
/** What a handler decides; the walker adds timing */
|
|
22
|
+
export type Outcome = Omit<NodeResult, "durationMs"> | "cancelled";
|
|
23
|
+
/** Runs one kind of node. A `branches` node names the branch it takes in `branch` */
|
|
24
|
+
export type Handler = (node: FlowNodeModel, run: HandlerRun) => Promise<Outcome>;
|
|
25
|
+
export declare const fail: (e: unknown) => Outcome;
|
|
26
|
+
/** Runs `list` for the node and turns the result into an outcome */
|
|
27
|
+
export declare function runNodeTests(node: FlowNodeModel, run: HandlerRun, list: NodeTest[]): Promise<Outcome>;
|
|
28
|
+
/** The checker's problem with an expression, as the builder would show it: a bypassed check fails the node the same way */
|
|
29
|
+
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, run.nodes))?.message;
|
|
@@ -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,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 } 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 } 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,165 @@
|
|
|
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 { STEP_NODES } from "../steps/catalog.js";
|
|
10
|
+
import { HANDLERS } from "./handlers/index.js";
|
|
11
|
+
import { runFlow } from "./run.js";
|
|
12
|
+
/** The flow has errors; nothing was run */
|
|
13
|
+
export class FlowCheckError extends Error {
|
|
14
|
+
constructor(problems) {
|
|
15
|
+
super(`This flow can't run:\n${problems.map((p) => ` - ${p.message}`).join("\n")}`);
|
|
16
|
+
this.problems = problems;
|
|
17
|
+
this.name = "FlowCheckError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Reads the project's config without keeping the project active (`withProject` is one at a time) */
|
|
21
|
+
async function configOf(options) {
|
|
22
|
+
if (options.config)
|
|
23
|
+
return options.config;
|
|
24
|
+
setActiveProject({ dir: path.resolve(options.projectDir ?? process.cwd()) });
|
|
25
|
+
try {
|
|
26
|
+
return await loadConfig();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
setActiveProject(undefined);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The environment: the option, else the flow's `envDefault` (when the project has it), else the config's default, else the first, else "default" */
|
|
33
|
+
export function chooseEnvironment(config, flow, option) {
|
|
34
|
+
const names = Object.keys(config.environments ?? {});
|
|
35
|
+
if (option)
|
|
36
|
+
return option;
|
|
37
|
+
if (flow.envDefault && names.includes(flow.envDefault))
|
|
38
|
+
return flow.envDefault;
|
|
39
|
+
return config.defaultEnvironment ?? names[0] ?? "default";
|
|
40
|
+
}
|
|
41
|
+
/** What the project says about a flow: its environment, tests and groups */
|
|
42
|
+
async function prepare(options) {
|
|
43
|
+
const projectDir = path.resolve(options.projectDir ?? process.cwd());
|
|
44
|
+
if (!options.flow && !options.flowId)
|
|
45
|
+
throw new Error("Give a flow or a flowId.");
|
|
46
|
+
const flow = withKeys(options.flow ?? (await readFlow(projectDir, options.flowId)));
|
|
47
|
+
const unsupported = options.unsupported ?? "fail";
|
|
48
|
+
// Config, environment, tests and groups (one project session, then it ends)
|
|
49
|
+
const config = await configOf({ ...options, projectDir });
|
|
50
|
+
const name = chooseEnvironment(config, flow, options.environment);
|
|
51
|
+
const environment = {
|
|
52
|
+
name,
|
|
53
|
+
variables: {
|
|
54
|
+
...(Object.prototype.hasOwnProperty.call(config.environments ?? {}, name)
|
|
55
|
+
? config.environments[name].variables
|
|
56
|
+
: {}),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const srcDir = path.resolve(projectDir, config.srcDir ?? DEFAULT_SRC_DIR);
|
|
60
|
+
const { tests, groups } = await withProject({
|
|
61
|
+
projectDir,
|
|
62
|
+
config,
|
|
63
|
+
...(options.dataDir !== undefined && { dataDir: options.dataDir }),
|
|
64
|
+
environment: name,
|
|
65
|
+
}, async (run) => {
|
|
66
|
+
const found = await (await TestRunner.init(run)).register();
|
|
67
|
+
const tests = found.map((t) => ({
|
|
68
|
+
id: testId(toPosix(path.relative(srcDir, t.file)), t.name),
|
|
69
|
+
file: t.file,
|
|
70
|
+
name: t.name,
|
|
71
|
+
relative: toPosix(path.relative(srcDir, t.file)),
|
|
72
|
+
}));
|
|
73
|
+
const groups = resolveGroups(config, tests.map((t) => ({ file: t.relative, name: t.name })), projectDir, srcDir);
|
|
74
|
+
return { tests, groups };
|
|
75
|
+
});
|
|
76
|
+
const problems = checkFlow(flow, {
|
|
77
|
+
tests: tests.map((t) => t.id),
|
|
78
|
+
groups: groups.map((g) => g.name),
|
|
79
|
+
environment,
|
|
80
|
+
handled: new Set(Object.keys(HANDLERS)),
|
|
81
|
+
unsupported,
|
|
82
|
+
nodes: STEP_NODES,
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
projectDir,
|
|
86
|
+
flow,
|
|
87
|
+
unsupported,
|
|
88
|
+
config,
|
|
89
|
+
name,
|
|
90
|
+
environment,
|
|
91
|
+
tests,
|
|
92
|
+
groups,
|
|
93
|
+
problems,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Checks a flow against the project without running it: its tests, groups and environment
|
|
98
|
+
* variables, and which kinds can run. Returns the flow (with keys) and every problem, warnings included.
|
|
99
|
+
*/
|
|
100
|
+
export async function checkFlowInProject(options) {
|
|
101
|
+
const { flow, problems } = await prepare(options);
|
|
102
|
+
return { flow, problems };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
|
|
106
|
+
* when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
|
|
107
|
+
* its tests through `runTests`, one after the other. One at a time per process.
|
|
108
|
+
*/
|
|
109
|
+
export async function runFlowInProject(options) {
|
|
110
|
+
const { projectDir, flow, unsupported, config, name, environment, tests, groups, problems, } = await prepare(options);
|
|
111
|
+
const errors = problems.filter((p) => p.level === "error");
|
|
112
|
+
if (errors.length)
|
|
113
|
+
throw new FlowCheckError(errors);
|
|
114
|
+
// Phase 3: walk it
|
|
115
|
+
const byId = new Map(tests.map((t) => [t.id, t]));
|
|
116
|
+
const nodeTests = {};
|
|
117
|
+
for (const node of flow.nodes) {
|
|
118
|
+
const ids = node.kind === "test"
|
|
119
|
+
? [node.ref?.testId ?? ""]
|
|
120
|
+
: node.kind === "group"
|
|
121
|
+
? (groups.find((g) => g.name === node.ref?.group)?.testIds ?? [])
|
|
122
|
+
: undefined;
|
|
123
|
+
if (!ids)
|
|
124
|
+
continue;
|
|
125
|
+
nodeTests[node.id] = ids.flatMap((id) => {
|
|
126
|
+
const t = byId.get(id);
|
|
127
|
+
return t ? [{ file: t.file, name: t.name }] : [];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const run = options.testRunner ?? runTests;
|
|
131
|
+
return runFlow({
|
|
132
|
+
flow,
|
|
133
|
+
nodeTests,
|
|
134
|
+
environment,
|
|
135
|
+
...(options.signal && { signal: options.signal }),
|
|
136
|
+
...(options.onEvent && { onEvent: options.onEvent }),
|
|
137
|
+
...(options.sleep && { sleep: options.sleep }),
|
|
138
|
+
unsupported,
|
|
139
|
+
runTests: async ({ tests, onEvent, signal }) => {
|
|
140
|
+
const summary = await run({
|
|
141
|
+
projectDir,
|
|
142
|
+
config,
|
|
143
|
+
...(options.dataDir !== undefined && { dataDir: options.dataDir }),
|
|
144
|
+
// The environment the expressions see, whenever the project defines it
|
|
145
|
+
...(Object.prototype.hasOwnProperty.call(config.environments ?? {}, name)
|
|
146
|
+
? { environment: name }
|
|
147
|
+
: {}),
|
|
148
|
+
...(options.headless !== undefined && { headless: options.headless }),
|
|
149
|
+
...(options.timeout !== undefined && { timeout: options.timeout }),
|
|
150
|
+
...(options.expectTimeout !== undefined && {
|
|
151
|
+
expectTimeout: options.expectTimeout,
|
|
152
|
+
}),
|
|
153
|
+
...(options.port !== undefined && { port: options.port }),
|
|
154
|
+
files: [...new Set(tests.map((t) => t.file))],
|
|
155
|
+
testNames: tests.map((t) => t.name),
|
|
156
|
+
reportPath: false,
|
|
157
|
+
signal,
|
|
158
|
+
onEvent,
|
|
159
|
+
});
|
|
160
|
+
if (signal.aborted)
|
|
161
|
+
return "cancelled";
|
|
162
|
+
return summary.status === "success" ? "passed" : "failed";
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { maskDeep, maskText } from "../../config/mask.js";
|
|
4
|
+
/** Follows a run's events and builds its report once it ends (or `finish` is given what is known) */
|
|
5
|
+
export class FlowReportBuilder {
|
|
6
|
+
constructor(flow) {
|
|
7
|
+
this.flow = flow;
|
|
8
|
+
this.environment = "";
|
|
9
|
+
this.tests = [];
|
|
10
|
+
this.started = Date.now();
|
|
11
|
+
this.startedAt = new Date(this.started).toISOString();
|
|
12
|
+
}
|
|
13
|
+
handle(event) {
|
|
14
|
+
if (event.type === "flow-start")
|
|
15
|
+
this.environment = event.environment;
|
|
16
|
+
if (event.type === "test")
|
|
17
|
+
this.collect(event.nodeId, event.event);
|
|
18
|
+
}
|
|
19
|
+
collect(nodeId, event) {
|
|
20
|
+
if (event.type === "test-end")
|
|
21
|
+
this.tests.push({ nodeId, ...event.result });
|
|
22
|
+
}
|
|
23
|
+
finish(summary, environment = this.environment) {
|
|
24
|
+
return {
|
|
25
|
+
flow: this.flow.id,
|
|
26
|
+
name: this.flow.name,
|
|
27
|
+
environment,
|
|
28
|
+
status: summary.status,
|
|
29
|
+
startedAt: this.startedAt,
|
|
30
|
+
durationMs: Date.now() - this.started,
|
|
31
|
+
nodes: summary.nodes,
|
|
32
|
+
vars: summary.vars,
|
|
33
|
+
tests: this.tests,
|
|
34
|
+
...(summary.error ? { error: summary.error } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Writes `<projectDir>/result/flows/<id>.json` with the registered secrets masked, as `report.json` is. Returns the path */
|
|
39
|
+
export async function writeFlowReport(projectDir, report) {
|
|
40
|
+
const file = path.join(path.resolve(projectDir), "result", "flows", `${report.flow}.json`);
|
|
41
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
42
|
+
await writeFile(file, JSON.stringify(maskDeep(report), null, 2), "utf8");
|
|
43
|
+
return file;
|
|
44
|
+
}
|
|
45
|
+
/** Hides the registered secrets in a line of output (a node's error) */
|
|
46
|
+
export const maskSecrets = (text) => maskText(text);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type AnyNodeDefinition, type FlowFile } from "../core/index.js";
|
|
2
|
+
import type { RunEvent } from "../../runner/reporter.js";
|
|
3
|
+
import type { FlowEvent, FlowSummary, NodeTest } from "./events.js";
|
|
4
|
+
import { type Handler } from "./handlers/index.js";
|
|
5
|
+
export interface RunFlowOptions {
|
|
6
|
+
flow: FlowFile;
|
|
7
|
+
/** The tests of each Test and Group node, by node id */
|
|
8
|
+
nodeTests: Record<string, NodeTest[]>;
|
|
9
|
+
environment: {
|
|
10
|
+
name: string;
|
|
11
|
+
variables: Record<string, string | number | boolean>;
|
|
12
|
+
};
|
|
13
|
+
/** @default "manual" */
|
|
14
|
+
trigger?: "manual";
|
|
15
|
+
/** @default now */
|
|
16
|
+
startedAt?: string;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
onEvent?: (event: FlowEvent) => void;
|
|
19
|
+
runTests: (input: {
|
|
20
|
+
tests: NodeTest[];
|
|
21
|
+
onEvent: (e: RunEvent) => void;
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
}) => Promise<"passed" | "failed" | "cancelled">;
|
|
24
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* What a node of a kind with no handler does:
|
|
27
|
+
* - `"fail"` (default): the run does not start (an error is thrown)
|
|
28
|
+
* - `"skip"`: the node is skipped with the note "Not supported yet"
|
|
29
|
+
*/
|
|
30
|
+
unsupported?: "fail" | "skip";
|
|
31
|
+
/** The handlers by kind. @default HANDLERS */
|
|
32
|
+
handlers?: Readonly<Record<string, Handler>>;
|
|
33
|
+
/** Definitions of kinds core doesn't have. @default the built-in steps */
|
|
34
|
+
nodes?: readonly AnyNodeDefinition[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walks the flow from Start by the shape of each node's kind: a step runs and
|
|
38
|
+
* follows its one way out; a node with a fixed list of branches follows the one its
|
|
39
|
+
* handler names and skips the others; a node with a dynamic list walks every branch in
|
|
40
|
+
* fill order; an end stops. Branches meet again at `meetOf`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function runFlow(options: RunFlowOptions): Promise<FlowSummary>;
|