@itmetsam/samurai-framework 0.2.3 → 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.
- 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 +97 -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 +42 -0
- package/dist/flows/core/check.js +202 -0
- package/dist/flows/core/expression/check.d.ts +21 -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 +7 -0
- package/dist/flows/core/expression/scope.js +25 -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 +16 -0
- package/dist/flows/core/index.js +16 -0
- package/dist/flows/core/keys.d.ts +8 -0
- package/dist/flows/core/keys.js +46 -0
- package/dist/flows/core/nodes/api.d.ts +9 -0
- package/dist/flows/core/nodes/api.js +12 -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/database.d.ts +9 -0
- package/dist/flows/core/nodes/database.js +12 -0
- package/dist/flows/core/nodes/define.d.ts +128 -0
- package/dist/flows/core/nodes/define.js +21 -0
- package/dist/flows/core/nodes/email.d.ts +9 -0
- package/dist/flows/core/nodes/email.js +12 -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 +107 -0
- package/dist/flows/core/nodes/index.js +81 -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/script.d.ts +9 -0
- package/dist/flows/core/nodes/script.js +12 -0
- package/dist/flows/core/nodes/set-variable.d.ts +13 -0
- package/dist/flows/core/nodes/set-variable.js +39 -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/nodes/wait.d.ts +15 -0
- package/dist/flows/core/nodes/wait.js +18 -0
- package/dist/flows/core/schema.d.ts +55 -0
- package/dist/flows/core/schema.js +33 -0
- package/dist/flows/run/events.d.ts +43 -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 +6 -0
- package/dist/flows/run/handlers/index.js +22 -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/set-variable.d.ts +2 -0
- package/dist/flows/run/handlers/set-variable.js +22 -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 +26 -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/handlers/wait.d.ts +2 -0
- package/dist/flows/run/handlers/wait.js +10 -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 +163 -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 +244 -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 +9 -1
|
@@ -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 beyond the ones `flows/core` registers (a handler needs one to be walked) */
|
|
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>;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { inFillOrder, meetOf, nodeDef, } from "../core/index.js";
|
|
2
|
+
import { HANDLERS } from "./handlers/index.js";
|
|
3
|
+
const defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
4
|
+
if (signal.aborted)
|
|
5
|
+
return reject(new Error("aborted"));
|
|
6
|
+
const onAbort = () => {
|
|
7
|
+
clearTimeout(timer);
|
|
8
|
+
reject(new Error("aborted"));
|
|
9
|
+
};
|
|
10
|
+
const timer = setTimeout(() => {
|
|
11
|
+
signal.removeEventListener("abort", onAbort);
|
|
12
|
+
resolve();
|
|
13
|
+
}, ms);
|
|
14
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
15
|
+
});
|
|
16
|
+
class ShapeError extends Error {
|
|
17
|
+
}
|
|
18
|
+
const dynamicBranches = (def) => def.shape === "branches" && !!def.branches && !Array.isArray(def.branches);
|
|
19
|
+
/**
|
|
20
|
+
* Walks the flow from Start by the shape of each node's kind: a step runs and
|
|
21
|
+
* follows its one way out; a node with a fixed list of branches follows the one its
|
|
22
|
+
* handler names and skips the others; a node with a dynamic list walks every branch in
|
|
23
|
+
* fill order; an end stops. Branches meet again at `meetOf`.
|
|
24
|
+
*/
|
|
25
|
+
export async function runFlow(options) {
|
|
26
|
+
const { flow, onEvent = () => { } } = options;
|
|
27
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
28
|
+
const handlers = options.handlers ?? HANDLERS;
|
|
29
|
+
const defOf = (kind) => options.nodes?.find((d) => d.kind === kind) ??
|
|
30
|
+
(() => {
|
|
31
|
+
try {
|
|
32
|
+
return nodeDef(kind);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
if ((options.unsupported ?? "fail") === "fail") {
|
|
39
|
+
const problems = flow.nodes
|
|
40
|
+
.map((n) => ({ n, def: defOf(n.kind) }))
|
|
41
|
+
.filter(({ n, def }) => def &&
|
|
42
|
+
(def.shape === "step" || def.shape === "branches") &&
|
|
43
|
+
!handlers[n.kind])
|
|
44
|
+
.map(({ def }) => `${def.label} can't run yet.`);
|
|
45
|
+
if (problems.length)
|
|
46
|
+
throw new Error(`This flow can't run: ${[...new Set(problems)].join(" ")}`);
|
|
47
|
+
}
|
|
48
|
+
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
49
|
+
const results = {};
|
|
50
|
+
const context = {
|
|
51
|
+
env: { ...options.environment.variables },
|
|
52
|
+
vars: {},
|
|
53
|
+
nodes: {},
|
|
54
|
+
run: {
|
|
55
|
+
environment: options.environment.name,
|
|
56
|
+
trigger: options.trigger ?? "manual",
|
|
57
|
+
startedAt: options.startedAt ?? new Date().toISOString(),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const handlerRun = {
|
|
61
|
+
flow,
|
|
62
|
+
environment: options.environment,
|
|
63
|
+
context,
|
|
64
|
+
nodeTests: options.nodeTests,
|
|
65
|
+
signal,
|
|
66
|
+
sleep: options.sleep ?? defaultSleep,
|
|
67
|
+
runTests: (tests, onTestEvent) => options.runTests({ tests, onEvent: onTestEvent, signal }),
|
|
68
|
+
onTestEvent: (nodeId, event) => onEvent({ type: "test", nodeId, event }),
|
|
69
|
+
};
|
|
70
|
+
let stopped;
|
|
71
|
+
const visited = new Set();
|
|
72
|
+
const record = (node, result) => {
|
|
73
|
+
results[node.id] = result;
|
|
74
|
+
if (node.key)
|
|
75
|
+
context.nodes[node.key] = {
|
|
76
|
+
status: result.status,
|
|
77
|
+
durationMs: result.durationMs,
|
|
78
|
+
error: result.error,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
const out = (id) => flow.edges.filter((e) => e.source === id);
|
|
82
|
+
/** Marks every node from `from` up to `stop` as skipped (a branch not taken) */
|
|
83
|
+
const skip = (from, stop) => {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const visit = (id) => {
|
|
86
|
+
if (id === stop || seen.has(id))
|
|
87
|
+
return;
|
|
88
|
+
seen.add(id);
|
|
89
|
+
const node = byId.get(id);
|
|
90
|
+
if (node && !results[id]) {
|
|
91
|
+
record(node, { status: "skipped" });
|
|
92
|
+
onEvent({
|
|
93
|
+
type: "node-end",
|
|
94
|
+
nodeId: id,
|
|
95
|
+
result: { status: "skipped" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
for (const e of out(id))
|
|
99
|
+
visit(e.target);
|
|
100
|
+
};
|
|
101
|
+
visit(from);
|
|
102
|
+
};
|
|
103
|
+
const shapeError = (message) => new ShapeError(`This flow's shape can't run: ${message}`);
|
|
104
|
+
async function runNode(node, def) {
|
|
105
|
+
if (visited.has(node.id))
|
|
106
|
+
throw shapeError(`${node.key ?? node.id} is reached twice.`);
|
|
107
|
+
visited.add(node.id);
|
|
108
|
+
if (signal.aborted) {
|
|
109
|
+
stopped = "cancelled";
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
onEvent({ type: "node-start", nodeId: node.id });
|
|
113
|
+
const began = Date.now();
|
|
114
|
+
const handler = handlers[node.kind];
|
|
115
|
+
const outcome = handler
|
|
116
|
+
? await handler(node, handlerRun)
|
|
117
|
+
: { status: "skipped", note: "Not supported yet" };
|
|
118
|
+
if (outcome === "cancelled" || signal.aborted) {
|
|
119
|
+
stopped = "cancelled";
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const result = { ...outcome, durationMs: Date.now() - began };
|
|
123
|
+
record(node, result);
|
|
124
|
+
// A node with a dynamic list of branches ends after them: walk() reports it then
|
|
125
|
+
if (dynamicBranches(def))
|
|
126
|
+
return result;
|
|
127
|
+
onEvent({ type: "node-end", nodeId: node.id, result });
|
|
128
|
+
if (result.status === "failed" && node.onFailure === "stop")
|
|
129
|
+
stopped = "failed";
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
/** Whether `target` is on the chain from `from` before `stop` */
|
|
133
|
+
const reachesBefore = (from, target, stop) => {
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
const visit = (id) => {
|
|
136
|
+
if (id === stop || seen.has(id))
|
|
137
|
+
return false;
|
|
138
|
+
if (id === target)
|
|
139
|
+
return true;
|
|
140
|
+
seen.add(id);
|
|
141
|
+
return out(id).some((e) => visit(e.target));
|
|
142
|
+
};
|
|
143
|
+
return visit(from);
|
|
144
|
+
};
|
|
145
|
+
/** Runs the chain from `from` up to `stop` (undefined: through End) */
|
|
146
|
+
async function walk(from, stop) {
|
|
147
|
+
let at = from;
|
|
148
|
+
while (at !== undefined && at !== stop && !stopped) {
|
|
149
|
+
const node = byId.get(at);
|
|
150
|
+
if (!node)
|
|
151
|
+
throw shapeError("an edge leads to a missing node.");
|
|
152
|
+
const def = defOf(node.kind);
|
|
153
|
+
if (!def)
|
|
154
|
+
throw shapeError(`${node.key ?? node.id} has an unknown kind (${node.kind}).`);
|
|
155
|
+
const result = await runNode(node, def);
|
|
156
|
+
if (!result || stopped)
|
|
157
|
+
return;
|
|
158
|
+
if (def.shape === "end")
|
|
159
|
+
return;
|
|
160
|
+
const edges = out(node.id);
|
|
161
|
+
if (def.shape === "branches") {
|
|
162
|
+
const meet = meetOf(flow, node.id);
|
|
163
|
+
if (!meet)
|
|
164
|
+
throw shapeError(`the branches of ${node.key ?? node.id} never meet.`);
|
|
165
|
+
if (!dynamicBranches(def)) {
|
|
166
|
+
const taken = result.status === "passed"
|
|
167
|
+
? edges.find((e) => e.sourceHandle === result.branch)
|
|
168
|
+
: undefined;
|
|
169
|
+
for (const e of edges)
|
|
170
|
+
if (e !== taken)
|
|
171
|
+
skip(e.target, meet);
|
|
172
|
+
if (taken)
|
|
173
|
+
await walk(taken.target, meet);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const opened = Date.now();
|
|
177
|
+
for (const e of inFillOrder(edges)) {
|
|
178
|
+
if (stopped)
|
|
179
|
+
break;
|
|
180
|
+
if (e.target !== meet)
|
|
181
|
+
await walk(e.target, meet);
|
|
182
|
+
}
|
|
183
|
+
if (stopped === "cancelled") {
|
|
184
|
+
// Stopped inside its branches: like any node cut short, it didn't run to the end
|
|
185
|
+
delete results[node.id];
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// The node fails when anything in its branches failed
|
|
189
|
+
const failed = flow.nodes.some((n) => results[n.id]?.status === "failed" &&
|
|
190
|
+
edges.some((e) => e.target !== meet && reachesBefore(e.target, n.id, meet)));
|
|
191
|
+
const durationMs = (result.durationMs ?? 0) + Date.now() - opened;
|
|
192
|
+
const ended = failed
|
|
193
|
+
? {
|
|
194
|
+
...result,
|
|
195
|
+
durationMs,
|
|
196
|
+
status: "failed",
|
|
197
|
+
error: "A branch failed.",
|
|
198
|
+
}
|
|
199
|
+
: { ...result, durationMs };
|
|
200
|
+
record(node, ended);
|
|
201
|
+
onEvent({ type: "node-end", nodeId: node.id, result: ended });
|
|
202
|
+
if (failed && node.onFailure === "stop")
|
|
203
|
+
stopped = "failed";
|
|
204
|
+
if (stopped)
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
at = meet;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (edges.length !== 1)
|
|
211
|
+
throw shapeError(`${node.key ?? node.id} has ${edges.length} ways out.`);
|
|
212
|
+
at = edges[0].target;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
onEvent({ type: "flow-start", environment: options.environment.name });
|
|
216
|
+
let error;
|
|
217
|
+
const start = flow.nodes.find((n) => defOf(n.kind)?.shape === "start");
|
|
218
|
+
try {
|
|
219
|
+
if (!start)
|
|
220
|
+
throw shapeError("it has no Start.");
|
|
221
|
+
await walk(start.id);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
if (!(e instanceof ShapeError))
|
|
225
|
+
throw e;
|
|
226
|
+
error = e.message;
|
|
227
|
+
}
|
|
228
|
+
for (const node of flow.nodes)
|
|
229
|
+
if (!results[node.id])
|
|
230
|
+
results[node.id] = { status: "not-run" };
|
|
231
|
+
const status = stopped === "cancelled"
|
|
232
|
+
? "cancelled"
|
|
233
|
+
: error || Object.values(results).some((r) => r.status === "failed")
|
|
234
|
+
? "failed"
|
|
235
|
+
: "passed";
|
|
236
|
+
const summary = {
|
|
237
|
+
status,
|
|
238
|
+
nodes: results,
|
|
239
|
+
vars: context.vars,
|
|
240
|
+
...(error ? { error } : {}),
|
|
241
|
+
};
|
|
242
|
+
onEvent({ type: "flow-end", summary });
|
|
243
|
+
return summary;
|
|
244
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SamuraiGroup } from "../types/config.js";
|
|
2
|
+
/** The test folder when the config names none */
|
|
3
|
+
export declare const DEFAULT_SRC_DIR = "./src";
|
|
4
|
+
/** The spec pattern when a group names none */
|
|
5
|
+
export declare const DEFAULT_TEST_MATCH = "**/*.spec.ts";
|
|
6
|
+
/** A test as groups see it: its spec file (relative to `srcDir`, or any path inside it) and full name */
|
|
7
|
+
export interface GroupTest {
|
|
8
|
+
file: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
/** A group with the tests it holds */
|
|
12
|
+
export interface ResolvedGroup {
|
|
13
|
+
name: string;
|
|
14
|
+
/** `picked`: the group lists its tests; `pattern`: `src` and `testMatch` select them */
|
|
15
|
+
kind: "picked" | "pattern";
|
|
16
|
+
/** What selects the tests of a pattern group, for display */
|
|
17
|
+
pattern?: string;
|
|
18
|
+
/** Ids (see `testId`) of the found tests in the group */
|
|
19
|
+
testIds: string[];
|
|
20
|
+
/** Picked tests that no longer exist */
|
|
21
|
+
missing: {
|
|
22
|
+
file: string;
|
|
23
|
+
title: string;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
|
26
|
+
/** Separators of any platform become `/` */
|
|
27
|
+
export declare const toPosix: (p: string) => string;
|
|
28
|
+
/** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
|
|
29
|
+
export declare const testId: (file: string, name: string) => string;
|
|
30
|
+
/** Works out which of `tests` (files relative to `srcDir`) belong to the group */
|
|
31
|
+
export declare function resolveGroup(group: SamuraiGroup, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup;
|
|
32
|
+
/** Resolves every group of the config */
|
|
33
|
+
export declare function resolveGroups(config: {
|
|
34
|
+
groups?: SamuraiGroup[];
|
|
35
|
+
}, tests: GroupTest[], projectDir: string, srcDir: string): ResolvedGroup[];
|
|
36
|
+
/** The group called `name`, or an error naming the groups there are */
|
|
37
|
+
export declare function findGroup<T extends {
|
|
38
|
+
name: string;
|
|
39
|
+
}>(groups: T[], name: string): T;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { matchesGlob, relative, resolve } from "node:path";
|
|
2
|
+
/** The test folder when the config names none */
|
|
3
|
+
export const DEFAULT_SRC_DIR = "./src";
|
|
4
|
+
/** The spec pattern when a group names none */
|
|
5
|
+
export const DEFAULT_TEST_MATCH = "**/*.spec.ts";
|
|
6
|
+
/** Separators of any platform become `/` */
|
|
7
|
+
export const toPosix = (p) => p.replace(/\\/g, "/");
|
|
8
|
+
/** The id of a test: its spec file relative to `srcDir` with `/` separators, then `::` and its full name */
|
|
9
|
+
export const testId = (file, name) => `${toPosix(file)}::${name}`;
|
|
10
|
+
/** Works out which of `tests` (files relative to `srcDir`) belong to the group */
|
|
11
|
+
export function resolveGroup(group, tests, projectDir, srcDir) {
|
|
12
|
+
const found = tests.map((t) => ({ ...t, id: testId(t.file, t.name) }));
|
|
13
|
+
// Tests picked by hand override src and testMatch
|
|
14
|
+
if (group.tests) {
|
|
15
|
+
const known = new Set(found.map((t) => t.id));
|
|
16
|
+
const picked = group.tests.map(({ file, title }) => ({
|
|
17
|
+
file,
|
|
18
|
+
title,
|
|
19
|
+
id: testId(file, title),
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
name: group.name,
|
|
23
|
+
kind: "picked",
|
|
24
|
+
testIds: picked.filter((t) => known.has(t.id)).map((t) => t.id),
|
|
25
|
+
missing: picked
|
|
26
|
+
.filter((t) => !known.has(t.id))
|
|
27
|
+
.map(({ file, title }) => ({ file, title })),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const base = group.src ? resolve(projectDir, group.src) : srcDir;
|
|
31
|
+
const pattern = group.testMatch ?? DEFAULT_TEST_MATCH;
|
|
32
|
+
const testIds = found
|
|
33
|
+
.filter((t) => {
|
|
34
|
+
const fromBase = toPosix(relative(base, resolve(srcDir, t.file)));
|
|
35
|
+
return !fromBase.startsWith("..") && matchesGlob(fromBase, pattern);
|
|
36
|
+
})
|
|
37
|
+
.map((t) => t.id);
|
|
38
|
+
return {
|
|
39
|
+
name: group.name,
|
|
40
|
+
kind: "pattern",
|
|
41
|
+
pattern: [group.src, group.testMatch].filter(Boolean).join(" · ") || pattern,
|
|
42
|
+
testIds,
|
|
43
|
+
missing: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Resolves every group of the config */
|
|
47
|
+
export function resolveGroups(config, tests, projectDir, srcDir) {
|
|
48
|
+
return (config.groups ?? []).map((group) => resolveGroup(group, tests, projectDir, srcDir));
|
|
49
|
+
}
|
|
50
|
+
/** The group called `name`, or an error naming the groups there are */
|
|
51
|
+
export function findGroup(groups, name) {
|
|
52
|
+
const group = groups.find((g) => g.name === name);
|
|
53
|
+
if (group)
|
|
54
|
+
return group;
|
|
55
|
+
throw new Error(groups.length === 0
|
|
56
|
+
? `Unknown group "${name}". No groups are configured.`
|
|
57
|
+
: `Unknown group "${name}". Groups: ${groups.map((g) => g.name).join(", ")}.`);
|
|
58
|
+
}
|
package/dist/runner/run.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TestSummary } from "../types/test.js";
|
|
2
2
|
import { type ProjectOptions } from "./project.js";
|
|
3
|
+
import type { ResolvedGroup } from "./groups.js";
|
|
3
4
|
import { type RunnerOptions } from "./test-runner.js";
|
|
4
5
|
export interface RunTestsOptions extends RunnerOptions, ProjectOptions {
|
|
5
6
|
}
|
|
@@ -21,7 +22,10 @@ export interface FoundTest {
|
|
|
21
22
|
}
|
|
22
23
|
/** Imports the project's specs without running anything and lists the tests the options select */
|
|
23
24
|
export declare function listTests(options?: RunTestsOptions): Promise<FoundTest[]>;
|
|
25
|
+
/** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
|
|
26
|
+
export declare function listGroups(options?: RunTestsOptions): Promise<ResolvedGroup[]>;
|
|
24
27
|
export type { RunEvent } from "./reporter.js";
|
|
25
28
|
export type { TestError, TestResult, TestSummary } from "../types/test.js";
|
|
29
|
+
export type { ResolvedGroup } from "./groups.js";
|
|
26
30
|
export type { RunnerOptions } from "./test-runner.js";
|
|
27
31
|
export type { ProjectOptions } from "./project.js";
|
package/dist/runner/run.js
CHANGED
|
@@ -32,3 +32,15 @@ export async function listTests(options = {}) {
|
|
|
32
32
|
expectTimeout,
|
|
33
33
|
}, async (run) => (await (await TestRunner.init(run, undefined, runner)).register()).map(({ name, file }) => ({ name, file })));
|
|
34
34
|
}
|
|
35
|
+
/** Imports the project's specs without running anything and lists the config's groups with the tests each holds */
|
|
36
|
+
export async function listGroups(options = {}) {
|
|
37
|
+
const { projectDir, config, dataDir, environment, timeout, expectTimeout, ...runner } = options;
|
|
38
|
+
return withProject({
|
|
39
|
+
projectDir,
|
|
40
|
+
config,
|
|
41
|
+
dataDir,
|
|
42
|
+
environment,
|
|
43
|
+
timeout,
|
|
44
|
+
expectTimeout,
|
|
45
|
+
}, async (run) => (await TestRunner.init(run, undefined, runner)).groups());
|
|
46
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { RegisteredTestCase } from "../types/test.js";
|
|
|
2
2
|
import type { PreparedRun } from "./prepare-run.js";
|
|
3
3
|
import { type RunEvent } from "./reporter.js";
|
|
4
4
|
import type { SamuraiGroup } from "../types/config.js";
|
|
5
|
+
import type { ResolvedGroup } from "./groups.js";
|
|
5
6
|
/** What decides which tests of the found files run, and how the browser starts */
|
|
6
7
|
export interface RunnerOptions {
|
|
7
8
|
/** Spec files to run, absolute or relative to the project folder. Default: every `*.spec.ts` under `srcDir` */
|
|
@@ -10,6 +11,8 @@ export interface RunnerOptions {
|
|
|
10
11
|
grep?: string | RegExp;
|
|
11
12
|
/** Only tests with exactly one of these full names (`describe` titles joined with " > ") */
|
|
12
13
|
testNames?: string[];
|
|
14
|
+
/** Only the tests of this group of the config's `groups` (unknown name: an error). Combines with the other filters */
|
|
15
|
+
group?: string;
|
|
13
16
|
/** Run the browser without a window. @default false */
|
|
14
17
|
headless?: boolean;
|
|
15
18
|
/** Remote debugging port of the browser. @default 9223 */
|
|
@@ -30,12 +33,19 @@ export default class TestRunner {
|
|
|
30
33
|
private options;
|
|
31
34
|
constructor(testFiles: string[], run: PreparedRun, group?: SamuraiGroup, options?: RunnerOptions);
|
|
32
35
|
static init(run: PreparedRun, group?: SamuraiGroup, options?: RunnerOptions): Promise<TestRunner>;
|
|
33
|
-
/** Runs the selected tests and returns the summary that was also written to the report */
|
|
34
36
|
/**
|
|
35
|
-
* Imports the spec files and returns
|
|
37
|
+
* Imports the spec files and returns every test they register. A fresh registry and a fresh import of each
|
|
36
38
|
* file, so a second run in this process registers its tests again (and sees edited specs, for files loaded as
|
|
37
39
|
* ES modules; CommonJS files stay cached until the process ends)
|
|
38
40
|
*/
|
|
41
|
+
private registerAll;
|
|
42
|
+
/** Id of a registered test: its spec file relative to `srcDir`, then its name */
|
|
43
|
+
private idOf;
|
|
44
|
+
/** The config's groups, resolved against the tests the specs register */
|
|
45
|
+
private resolveAll;
|
|
46
|
+
/** The groups of the config with the tests each holds; see `listGroups` */
|
|
47
|
+
groups(): Promise<ResolvedGroup[]>;
|
|
48
|
+
/** Imports the spec files and returns the tests the options select: by `group`, `testNames` and `grep` */
|
|
39
49
|
register(): Promise<RegisteredTestCase[]>;
|
|
40
50
|
/** Runs the selected tests and returns the summary that was also written to the report */
|
|
41
51
|
start(): Promise<import("../types/test.js").TestSummary>;
|
|
@@ -3,7 +3,7 @@ import { glob } from "node:fs/promises";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
5
|
import { Browser } from "../browser/browser.js";
|
|
6
|
-
import { projectDir, readConfig } from "../config/config.js";
|
|
6
|
+
import { loadConfig, projectDir, readConfig } from "../config/config.js";
|
|
7
7
|
import logger from "../logger/index.js";
|
|
8
8
|
import { assertModuleProject } from "./module-type.js";
|
|
9
9
|
import { clearRegistry, registeredTests } from "./registry.js";
|
|
@@ -14,6 +14,7 @@ import Page from "../browser/page.js";
|
|
|
14
14
|
import { takePendingAssertions } from "../assert/expect.js";
|
|
15
15
|
import { toTestError } from "./test-error.js";
|
|
16
16
|
import { ConnectionClosedError } from "../transport/bidi-connection.js";
|
|
17
|
+
import { DEFAULT_SRC_DIR, findGroup, resolveGroups, testId, toPosix, } from "./groups.js";
|
|
17
18
|
import { judgePageLogs } from "./page-logs-report.js";
|
|
18
19
|
/** Whether a test with this full name is selected by the options */
|
|
19
20
|
export function isSelected(name, { grep, testNames }) {
|
|
@@ -57,13 +58,12 @@ export default class TestRunner {
|
|
|
57
58
|
logger.debug("Found %d tests in srcDir", testFiles.length);
|
|
58
59
|
return new TestRunner(testFiles.map((file) => path.join(cwd, file)), run, group, options);
|
|
59
60
|
}
|
|
60
|
-
/** Runs the selected tests and returns the summary that was also written to the report */
|
|
61
61
|
/**
|
|
62
|
-
* Imports the spec files and returns
|
|
62
|
+
* Imports the spec files and returns every test they register. A fresh registry and a fresh import of each
|
|
63
63
|
* file, so a second run in this process registers its tests again (and sees edited specs, for files loaded as
|
|
64
64
|
* ES modules; CommonJS files stay cached until the process ends)
|
|
65
65
|
*/
|
|
66
|
-
async
|
|
66
|
+
async registerAll() {
|
|
67
67
|
clearRegistry();
|
|
68
68
|
const stamp = `?run=${Date.now().toString(36)}`;
|
|
69
69
|
for (const file of this.testFiles)
|
|
@@ -72,8 +72,38 @@ export default class TestRunner {
|
|
|
72
72
|
logger.verbose("Trying to register file: %s", file);
|
|
73
73
|
await import(pathToFileURL(file).href + stamp);
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
return [...registeredTests()];
|
|
76
|
+
}
|
|
77
|
+
/** Id of a registered test: its spec file relative to `srcDir`, then its name */
|
|
78
|
+
idOf(test, srcDir) {
|
|
79
|
+
return testId(toPosix(path.relative(srcDir, test.file)), test.name);
|
|
80
|
+
}
|
|
81
|
+
/** The config's groups, resolved against the tests the specs register */
|
|
82
|
+
async resolveAll(tests) {
|
|
83
|
+
const config = await loadConfig();
|
|
84
|
+
const dir = projectDir();
|
|
85
|
+
const srcDir = path.resolve(dir, config.srcDir ?? DEFAULT_SRC_DIR);
|
|
86
|
+
const groups = resolveGroups(config, tests.map(({ file, name }) => ({
|
|
87
|
+
file: toPosix(path.relative(srcDir, file)),
|
|
88
|
+
name,
|
|
89
|
+
})), dir, srcDir);
|
|
90
|
+
return { groups, srcDir };
|
|
91
|
+
}
|
|
92
|
+
/** The groups of the config with the tests each holds; see `listGroups` */
|
|
93
|
+
async groups() {
|
|
94
|
+
return (await this.resolveAll(await this.registerAll())).groups;
|
|
95
|
+
}
|
|
96
|
+
/** Imports the spec files and returns the tests the options select: by `group`, `testNames` and `grep` */
|
|
97
|
+
async register() {
|
|
98
|
+
const all = await this.registerAll();
|
|
99
|
+
const { group } = this.options;
|
|
100
|
+
let inGroup = () => true;
|
|
101
|
+
if (group !== undefined) {
|
|
102
|
+
const { groups, srcDir } = await this.resolveAll(all);
|
|
103
|
+
const members = new Set(findGroup(groups, group).testIds);
|
|
104
|
+
inGroup = (test) => members.has(this.idOf(test, srcDir));
|
|
105
|
+
}
|
|
106
|
+
return all.filter((test) => inGroup(test) && isSelected(test.name, this.options));
|
|
77
107
|
}
|
|
78
108
|
/** Runs the selected tests and returns the summary that was also written to the report */
|
|
79
109
|
async start() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itmetsam/samurai-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
|
|
5
5
|
"main": "dist/api.js",
|
|
6
6
|
"exports": {
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
"types": "./dist/browser/index.d.ts",
|
|
29
29
|
"default": "./dist/browser/index.js"
|
|
30
30
|
},
|
|
31
|
+
"./flows": {
|
|
32
|
+
"types": "./dist/flows/core/index.d.ts",
|
|
33
|
+
"default": "./dist/flows/core/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./flows/run": {
|
|
36
|
+
"types": "./dist/flows/run/index.d.ts",
|
|
37
|
+
"default": "./dist/flows/run/index.js"
|
|
38
|
+
},
|
|
31
39
|
"./package.json": "./package.json"
|
|
32
40
|
},
|
|
33
41
|
"scripts": {
|