@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,250 @@
|
|
|
1
|
+
import { inFillOrder, meetOf, nodeDef, } from "../core/index.js";
|
|
2
|
+
import logger from "../../logger/index.js";
|
|
3
|
+
import { STEP_NODES } from "../steps/catalog.js";
|
|
4
|
+
import { HANDLERS } from "./handlers/index.js";
|
|
5
|
+
const defaultSleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
6
|
+
if (signal.aborted)
|
|
7
|
+
return reject(new Error("aborted"));
|
|
8
|
+
const onAbort = () => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
reject(new Error("aborted"));
|
|
11
|
+
};
|
|
12
|
+
const timer = setTimeout(() => {
|
|
13
|
+
signal.removeEventListener("abort", onAbort);
|
|
14
|
+
resolve();
|
|
15
|
+
}, ms);
|
|
16
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
17
|
+
});
|
|
18
|
+
class ShapeError extends Error {
|
|
19
|
+
}
|
|
20
|
+
const dynamicBranches = (def) => def.shape === "branches" && !!def.branches && !Array.isArray(def.branches);
|
|
21
|
+
/**
|
|
22
|
+
* Walks the flow from Start by the shape of each node's kind: a step runs and
|
|
23
|
+
* follows its one way out; a node with a fixed list of branches follows the one its
|
|
24
|
+
* handler names and skips the others; a node with a dynamic list walks every branch in
|
|
25
|
+
* fill order; an end stops. Branches meet again at `meetOf`.
|
|
26
|
+
*/
|
|
27
|
+
export async function runFlow(options) {
|
|
28
|
+
const { flow, onEvent = () => { } } = options;
|
|
29
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
30
|
+
const handlers = options.handlers ?? HANDLERS;
|
|
31
|
+
const nodes = options.nodes ?? STEP_NODES;
|
|
32
|
+
const defOf = (kind) => nodes.find((d) => d.kind === kind) ??
|
|
33
|
+
(() => {
|
|
34
|
+
try {
|
|
35
|
+
return nodeDef(kind);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
if ((options.unsupported ?? "fail") === "fail") {
|
|
42
|
+
const problems = flow.nodes
|
|
43
|
+
.map((n) => ({ n, def: defOf(n.kind) }))
|
|
44
|
+
.filter(({ n, def }) => def &&
|
|
45
|
+
(def.shape === "step" || def.shape === "branches") &&
|
|
46
|
+
!handlers[n.kind])
|
|
47
|
+
.map(({ def }) => `${def.label} can't run yet.`);
|
|
48
|
+
if (problems.length)
|
|
49
|
+
throw new Error(`This flow can't run: ${[...new Set(problems)].join(" ")}`);
|
|
50
|
+
}
|
|
51
|
+
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
52
|
+
const results = {};
|
|
53
|
+
const context = {
|
|
54
|
+
env: { ...options.environment.variables },
|
|
55
|
+
vars: {},
|
|
56
|
+
nodes: {},
|
|
57
|
+
run: {
|
|
58
|
+
environment: options.environment.name,
|
|
59
|
+
trigger: options.trigger ?? "manual",
|
|
60
|
+
startedAt: options.startedAt ?? new Date().toISOString(),
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const handlerRun = {
|
|
64
|
+
flow,
|
|
65
|
+
environment: options.environment,
|
|
66
|
+
context,
|
|
67
|
+
nodeTests: options.nodeTests,
|
|
68
|
+
signal,
|
|
69
|
+
sleep: options.sleep ?? defaultSleep,
|
|
70
|
+
runTests: (tests, onTestEvent) => options.runTests({ tests, onEvent: onTestEvent, signal }),
|
|
71
|
+
onTestEvent: (nodeId, event) => onEvent({ type: "test", nodeId, event }),
|
|
72
|
+
nodes,
|
|
73
|
+
log: (nodeId, level, text) => logger[level](`[${nodeId}] ${text}`),
|
|
74
|
+
};
|
|
75
|
+
let stopped;
|
|
76
|
+
const visited = new Set();
|
|
77
|
+
const record = (node, result) => {
|
|
78
|
+
results[node.id] = result;
|
|
79
|
+
if (node.key)
|
|
80
|
+
context.nodes[node.key] = {
|
|
81
|
+
...result.outputs,
|
|
82
|
+
status: result.status,
|
|
83
|
+
durationMs: result.durationMs,
|
|
84
|
+
error: result.error,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const out = (id) => flow.edges.filter((e) => e.source === id);
|
|
88
|
+
/** Marks every node from `from` up to `stop` as skipped (a branch not taken) */
|
|
89
|
+
const skip = (from, stop) => {
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const visit = (id) => {
|
|
92
|
+
if (id === stop || seen.has(id))
|
|
93
|
+
return;
|
|
94
|
+
seen.add(id);
|
|
95
|
+
const node = byId.get(id);
|
|
96
|
+
if (node && !results[id]) {
|
|
97
|
+
record(node, { status: "skipped" });
|
|
98
|
+
onEvent({
|
|
99
|
+
type: "node-end",
|
|
100
|
+
nodeId: id,
|
|
101
|
+
result: { status: "skipped" },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
for (const e of out(id))
|
|
105
|
+
visit(e.target);
|
|
106
|
+
};
|
|
107
|
+
visit(from);
|
|
108
|
+
};
|
|
109
|
+
const shapeError = (message) => new ShapeError(`This flow's shape can't run: ${message}`);
|
|
110
|
+
async function runNode(node, def) {
|
|
111
|
+
if (visited.has(node.id))
|
|
112
|
+
throw shapeError(`${node.key ?? node.id} is reached twice.`);
|
|
113
|
+
visited.add(node.id);
|
|
114
|
+
if (signal.aborted) {
|
|
115
|
+
stopped = "cancelled";
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
onEvent({ type: "node-start", nodeId: node.id });
|
|
119
|
+
const began = Date.now();
|
|
120
|
+
const handler = handlers[node.kind];
|
|
121
|
+
const outcome = handler
|
|
122
|
+
? await handler(node, handlerRun)
|
|
123
|
+
: { status: "skipped", note: "Not supported yet" };
|
|
124
|
+
if (outcome === "cancelled" || signal.aborted) {
|
|
125
|
+
stopped = "cancelled";
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const result = { ...outcome, durationMs: Date.now() - began };
|
|
129
|
+
record(node, result);
|
|
130
|
+
// A node with a dynamic list of branches ends after them: walk() reports it then
|
|
131
|
+
if (dynamicBranches(def))
|
|
132
|
+
return result;
|
|
133
|
+
onEvent({ type: "node-end", nodeId: node.id, result });
|
|
134
|
+
if (result.status === "failed" && node.onFailure === "stop")
|
|
135
|
+
stopped = "failed";
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
/** Whether `target` is on the chain from `from` before `stop` */
|
|
139
|
+
const reachesBefore = (from, target, stop) => {
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
const visit = (id) => {
|
|
142
|
+
if (id === stop || seen.has(id))
|
|
143
|
+
return false;
|
|
144
|
+
if (id === target)
|
|
145
|
+
return true;
|
|
146
|
+
seen.add(id);
|
|
147
|
+
return out(id).some((e) => visit(e.target));
|
|
148
|
+
};
|
|
149
|
+
return visit(from);
|
|
150
|
+
};
|
|
151
|
+
/** Runs the chain from `from` up to `stop` (undefined: through End) */
|
|
152
|
+
async function walk(from, stop) {
|
|
153
|
+
let at = from;
|
|
154
|
+
while (at !== undefined && at !== stop && !stopped) {
|
|
155
|
+
const node = byId.get(at);
|
|
156
|
+
if (!node)
|
|
157
|
+
throw shapeError("an edge leads to a missing node.");
|
|
158
|
+
const def = defOf(node.kind);
|
|
159
|
+
if (!def)
|
|
160
|
+
throw shapeError(`${node.key ?? node.id} has an unknown kind (${node.kind}).`);
|
|
161
|
+
const result = await runNode(node, def);
|
|
162
|
+
if (!result || stopped)
|
|
163
|
+
return;
|
|
164
|
+
if (def.shape === "end")
|
|
165
|
+
return;
|
|
166
|
+
const edges = out(node.id);
|
|
167
|
+
if (def.shape === "branches") {
|
|
168
|
+
const meet = meetOf(flow, node.id);
|
|
169
|
+
if (!meet)
|
|
170
|
+
throw shapeError(`the branches of ${node.key ?? node.id} never meet.`);
|
|
171
|
+
if (!dynamicBranches(def)) {
|
|
172
|
+
const taken = result.status === "passed"
|
|
173
|
+
? edges.find((e) => e.sourceHandle === result.branch)
|
|
174
|
+
: undefined;
|
|
175
|
+
for (const e of edges)
|
|
176
|
+
if (e !== taken)
|
|
177
|
+
skip(e.target, meet);
|
|
178
|
+
if (taken)
|
|
179
|
+
await walk(taken.target, meet);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const opened = Date.now();
|
|
183
|
+
for (const e of inFillOrder(edges)) {
|
|
184
|
+
if (stopped)
|
|
185
|
+
break;
|
|
186
|
+
if (e.target !== meet)
|
|
187
|
+
await walk(e.target, meet);
|
|
188
|
+
}
|
|
189
|
+
if (stopped === "cancelled") {
|
|
190
|
+
// Stopped inside its branches: like any node cut short, it didn't run to the end
|
|
191
|
+
delete results[node.id];
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// The node fails when anything in its branches failed
|
|
195
|
+
const failed = flow.nodes.some((n) => results[n.id]?.status === "failed" &&
|
|
196
|
+
edges.some((e) => e.target !== meet && reachesBefore(e.target, n.id, meet)));
|
|
197
|
+
const durationMs = (result.durationMs ?? 0) + Date.now() - opened;
|
|
198
|
+
const ended = failed
|
|
199
|
+
? {
|
|
200
|
+
...result,
|
|
201
|
+
durationMs,
|
|
202
|
+
status: "failed",
|
|
203
|
+
error: "A branch failed.",
|
|
204
|
+
}
|
|
205
|
+
: { ...result, durationMs };
|
|
206
|
+
record(node, ended);
|
|
207
|
+
onEvent({ type: "node-end", nodeId: node.id, result: ended });
|
|
208
|
+
if (failed && node.onFailure === "stop")
|
|
209
|
+
stopped = "failed";
|
|
210
|
+
if (stopped)
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
at = meet;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (edges.length !== 1)
|
|
217
|
+
throw shapeError(`${node.key ?? node.id} has ${edges.length} ways out.`);
|
|
218
|
+
at = edges[0].target;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
onEvent({ type: "flow-start", environment: options.environment.name });
|
|
222
|
+
let error;
|
|
223
|
+
const start = flow.nodes.find((n) => defOf(n.kind)?.shape === "start");
|
|
224
|
+
try {
|
|
225
|
+
if (!start)
|
|
226
|
+
throw shapeError("it has no Start.");
|
|
227
|
+
await walk(start.id);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
if (!(e instanceof ShapeError))
|
|
231
|
+
throw e;
|
|
232
|
+
error = e.message;
|
|
233
|
+
}
|
|
234
|
+
for (const node of flow.nodes)
|
|
235
|
+
if (!results[node.id])
|
|
236
|
+
results[node.id] = { status: "not-run" };
|
|
237
|
+
const status = stopped === "cancelled"
|
|
238
|
+
? "cancelled"
|
|
239
|
+
: error || Object.values(results).some((r) => r.status === "failed")
|
|
240
|
+
? "failed"
|
|
241
|
+
: "passed";
|
|
242
|
+
const summary = {
|
|
243
|
+
status,
|
|
244
|
+
nodes: results,
|
|
245
|
+
vars: context.vars,
|
|
246
|
+
...(error ? { error } : {}),
|
|
247
|
+
};
|
|
248
|
+
onEvent({ type: "flow-end", summary });
|
|
249
|
+
return summary;
|
|
250
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Any response passes and outputs its status; a network error or a bad URL fails the node */
|
|
2
|
+
export declare const api: import("./define.js").StepDefinition<import("./define.js").RunConfigOf<readonly [{
|
|
3
|
+
readonly path: "config.method";
|
|
4
|
+
readonly type: "select";
|
|
5
|
+
readonly label: "Method";
|
|
6
|
+
readonly options: {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}[];
|
|
10
|
+
readonly default: "GET";
|
|
11
|
+
}, {
|
|
12
|
+
readonly path: "config.url";
|
|
13
|
+
readonly type: "expression";
|
|
14
|
+
readonly label: "URL";
|
|
15
|
+
readonly placeholder: "env.baseUrl + \"/health\"";
|
|
16
|
+
readonly default: "";
|
|
17
|
+
}, {
|
|
18
|
+
readonly path: "config.body";
|
|
19
|
+
readonly type: "code";
|
|
20
|
+
readonly label: "Body";
|
|
21
|
+
readonly placeholder: "{ \"name\": \"test\" }";
|
|
22
|
+
readonly default: "";
|
|
23
|
+
}]>>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineStep } from "./define.js";
|
|
2
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({ label: m, value: m }));
|
|
3
|
+
const isJson = (text) => {
|
|
4
|
+
try {
|
|
5
|
+
JSON.parse(text);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
/** Any response passes and outputs its status; a network error or a bad URL fails the node */
|
|
13
|
+
export const api = defineStep({
|
|
14
|
+
kind: "api",
|
|
15
|
+
label: "HTTP request",
|
|
16
|
+
icon: "globe",
|
|
17
|
+
section: "integrations",
|
|
18
|
+
fields: [
|
|
19
|
+
{ path: "config.method", type: "select", label: "Method", options: METHODS, default: "GET" },
|
|
20
|
+
{ path: "config.url", type: "expression", label: "URL", placeholder: 'env.baseUrl + "/health"', default: "" },
|
|
21
|
+
{ path: "config.body", type: "code", label: "Body", placeholder: '{ "name": "test" }', default: "" },
|
|
22
|
+
],
|
|
23
|
+
summary: "{config.method} {config.url}",
|
|
24
|
+
outputs: [
|
|
25
|
+
{ name: "statusCode", label: "Status code" },
|
|
26
|
+
{ name: "body", label: "Response body" },
|
|
27
|
+
],
|
|
28
|
+
async run({ config, signal }) {
|
|
29
|
+
const body = config.method !== "GET" && config.body ? config.body : undefined;
|
|
30
|
+
const res = await fetch(String(config.url), {
|
|
31
|
+
method: config.method,
|
|
32
|
+
signal,
|
|
33
|
+
...(body !== undefined && {
|
|
34
|
+
body,
|
|
35
|
+
headers: { "content-type": isJson(body) ? "application/json" : "text/plain" },
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
return { outputs: { statusCode: res.status, body: await res.text() } };
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AnyNodeDefinition, FlowFile, FlowProblem, StepInfo } from "../core/index.js";
|
|
2
|
+
import type { Handler } from "../run/handlers/support.js";
|
|
3
|
+
import { type StepDefinition } from "./define.js";
|
|
4
|
+
/** The built-in steps, in library order */
|
|
5
|
+
export declare const STEPS: readonly StepDefinition<any>[];
|
|
6
|
+
export declare const STEP_NODES: readonly AnyNodeDefinition[];
|
|
7
|
+
export declare const STEP_HANDLERS: Readonly<Record<string, Handler>>;
|
|
8
|
+
/** The steps as data, for the app */
|
|
9
|
+
export declare const stepCatalog: (steps?: readonly StepDefinition<any>[]) => StepInfo[];
|
|
10
|
+
/** The project's steps; the built-in ones for now (test-project steps come later) */
|
|
11
|
+
export declare function listSteps(): Promise<StepInfo[]>;
|
|
12
|
+
/** Only the steps' own `validate` problems: the app checks everything else itself */
|
|
13
|
+
export declare function validateSteps(flow: FlowFile, options: {
|
|
14
|
+
tests?: string[];
|
|
15
|
+
groups?: string[];
|
|
16
|
+
}): FlowProblem[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { api } from "./api.js";
|
|
2
|
+
import { infoOf, stepNode } from "./define.js";
|
|
3
|
+
import { stepHandler } from "./handler.js";
|
|
4
|
+
import { setVariable } from "./set-variable.js";
|
|
5
|
+
import { wait } from "./wait.js";
|
|
6
|
+
/** The built-in steps, in library order */
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
export const STEPS = [wait, setVariable, api];
|
|
9
|
+
export const STEP_NODES = STEPS.map(stepNode);
|
|
10
|
+
export const STEP_HANDLERS = Object.fromEntries(STEPS.map((s) => [s.kind, stepHandler(s)]));
|
|
11
|
+
/** The steps as data, for the app */
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
export const stepCatalog = (steps = STEPS) => steps.map(infoOf);
|
|
14
|
+
/** The project's steps; the built-in ones for now (test-project steps come later) */
|
|
15
|
+
export async function listSteps() {
|
|
16
|
+
return stepCatalog();
|
|
17
|
+
}
|
|
18
|
+
/** Only the steps' own `validate` problems: the app checks everything else itself */
|
|
19
|
+
export function validateSteps(flow, options) {
|
|
20
|
+
const tests = options.tests ? new Set(options.tests) : undefined;
|
|
21
|
+
const groups = options.groups ? new Set(options.groups) : undefined;
|
|
22
|
+
return flow.nodes.flatMap((n) => {
|
|
23
|
+
const step = STEPS.find((s) => s.kind === n.kind);
|
|
24
|
+
if (!step?.validate)
|
|
25
|
+
return [];
|
|
26
|
+
const def = STEP_NODES.find((d) => d.kind === n.kind);
|
|
27
|
+
return step.validate({ ...n, config: { ...Object.fromEntries(step.fields.map((f) => [f.path.slice(7), "default" in f ? f.default : undefined])), ...n.config } }, { label: def.label, tests, groups });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type AnyNodeDefinition, type NodeCoreDefinition, type StepField, type StepInfo } from "../core/index.js";
|
|
2
|
+
type RunValueOf<F> = F extends {
|
|
3
|
+
type: "expression";
|
|
4
|
+
} ? unknown : F extends {
|
|
5
|
+
type: "number";
|
|
6
|
+
} ? number : F extends {
|
|
7
|
+
type: "switch";
|
|
8
|
+
} ? boolean : string;
|
|
9
|
+
/** `config` as `run` sees it: expression fields arrive evaluated */
|
|
10
|
+
export type RunConfigOf<F extends readonly StepField[]> = {
|
|
11
|
+
[K in F[number] as K["path"] extends `config.${infer P}` ? P : never]: RunValueOf<K>;
|
|
12
|
+
};
|
|
13
|
+
export interface StepRunInput<C> {
|
|
14
|
+
config: C;
|
|
15
|
+
env: Readonly<Record<string, unknown>>;
|
|
16
|
+
vars: Readonly<Record<string, unknown>>;
|
|
17
|
+
node: {
|
|
18
|
+
id: string;
|
|
19
|
+
key?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Aborted when the run is stopped */
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
/** Waits, unless the run is stopped first */
|
|
25
|
+
sleep: (ms: number) => Promise<void>;
|
|
26
|
+
log: (level: "info" | "warn" | "error", text: string) => void;
|
|
27
|
+
}
|
|
28
|
+
export interface StepResult {
|
|
29
|
+
/** Declared in `outputs`; readable later as `nodes.<key>.<name>` */
|
|
30
|
+
outputs?: Record<string, unknown>;
|
|
31
|
+
/** Only the name the `writesVar` field holds */
|
|
32
|
+
vars?: Record<string, unknown>;
|
|
33
|
+
note?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface StepDefinition<C = Record<string, unknown>> extends StepInfo {
|
|
36
|
+
/** Problems with this node alone; runs in the engine (checks and before a run) */
|
|
37
|
+
validate?: NodeCoreDefinition<string, any>["validate"];
|
|
38
|
+
/** What the step does in a run: return to pass, throw to fail */
|
|
39
|
+
run: (input: StepRunInput<C>) => Promise<StepResult | void>;
|
|
40
|
+
}
|
|
41
|
+
/** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
|
|
42
|
+
export declare function defineStep<const F extends readonly StepField[]>(def: Omit<StepDefinition<RunConfigOf<F>>, "fields"> & {
|
|
43
|
+
fields: F;
|
|
44
|
+
}): StepDefinition<RunConfigOf<F>>;
|
|
45
|
+
/** The step as data, without its functions: what the catalog holds */
|
|
46
|
+
export declare function infoOf(step: StepDefinition<never>): StepInfo;
|
|
47
|
+
/** The definition the walker and checkFlow use for a step */
|
|
48
|
+
export declare const stepNode: (step: StepDefinition<any>) => AnyNodeDefinition;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { CLOSED_KINDS, RESERVED_OUTPUTS, STEP_KIND, stepNodeOf, } from "../core/index.js";
|
|
2
|
+
/** Declares a flow step: one way in, one way out. Throws for a definition that can't work. */
|
|
3
|
+
export function defineStep(def) {
|
|
4
|
+
const name = `Step "${def.kind}"`;
|
|
5
|
+
if (CLOSED_KINDS.includes(def.kind))
|
|
6
|
+
throw new Error(`${name}: "${def.kind}" is a closed kind; steps can't replace it.`);
|
|
7
|
+
if (!STEP_KIND.test(def.kind))
|
|
8
|
+
throw new Error(`${name}: "${def.kind}" isn't a valid step kind. Use lowercase letters, digits and -.`);
|
|
9
|
+
for (const f of def.fields) {
|
|
10
|
+
if (!f.path.startsWith("config."))
|
|
11
|
+
throw new Error(`${name}: field "${f.path}" must be a config. path.`);
|
|
12
|
+
if (f.pattern !== undefined)
|
|
13
|
+
try {
|
|
14
|
+
new RegExp(f.pattern);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`${name}: field "${f.path}" has a pattern that isn't a valid regular expression.`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const outputs = (def.outputs ?? []).map((o) => o.name);
|
|
21
|
+
for (const o of outputs) {
|
|
22
|
+
if (RESERVED_OUTPUTS.includes(o))
|
|
23
|
+
throw new Error(`${name}: output "${o}" is reserved.`);
|
|
24
|
+
if (outputs.indexOf(o) !== outputs.lastIndexOf(o))
|
|
25
|
+
throw new Error(`${name}: output "${o}" is declared twice.`);
|
|
26
|
+
}
|
|
27
|
+
if (def.writesVar !== undefined && !def.fields.some((f) => f.path === def.writesVar))
|
|
28
|
+
throw new Error(`${name}: writesVar "${def.writesVar}" isn't one of its fields.`);
|
|
29
|
+
return def;
|
|
30
|
+
}
|
|
31
|
+
/** The step as data, without its functions: what the catalog holds */
|
|
32
|
+
export function infoOf(step) {
|
|
33
|
+
const { run: _run, validate: _validate, ...info } = step;
|
|
34
|
+
void _run;
|
|
35
|
+
void _validate;
|
|
36
|
+
return JSON.parse(JSON.stringify(info));
|
|
37
|
+
}
|
|
38
|
+
/** The definition the walker and checkFlow use for a step */
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
export const stepNode = (step) => stepNodeOf(infoOf(step), step.validate);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { evaluateText, getPath, withDefaults } from "../core/index.js";
|
|
2
|
+
import { fail, problemAt } from "../run/handlers/support.js";
|
|
3
|
+
import { stepNode } from "./define.js";
|
|
4
|
+
const keysOf = (o) => Object.keys(o ?? {});
|
|
5
|
+
/** The one handler every step runs through */
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
export function stepHandler(step) {
|
|
8
|
+
const def = stepNode(step);
|
|
9
|
+
return async (node, run) => {
|
|
10
|
+
const typed = withDefaults(def, node);
|
|
11
|
+
const config = { ...typed.config };
|
|
12
|
+
for (const f of step.fields) {
|
|
13
|
+
if (f.type !== "expression")
|
|
14
|
+
continue;
|
|
15
|
+
const text = String(getPath(typed, f.path) ?? "");
|
|
16
|
+
const problem = problemAt(node, run, text);
|
|
17
|
+
if (problem)
|
|
18
|
+
return { status: "failed", error: problem };
|
|
19
|
+
try {
|
|
20
|
+
config[f.path.slice("config.".length)] = evaluateText(text, run.context);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return fail(e);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// A step that ignores the signal must not hold the run: Stop wins the race
|
|
27
|
+
const stopped = new Promise((resolve) => {
|
|
28
|
+
if (run.signal.aborted)
|
|
29
|
+
resolve("stopped");
|
|
30
|
+
run.signal.addEventListener("abort", () => resolve("stopped"), { once: true });
|
|
31
|
+
});
|
|
32
|
+
let result;
|
|
33
|
+
try {
|
|
34
|
+
result = await Promise.race([
|
|
35
|
+
step.run({
|
|
36
|
+
config,
|
|
37
|
+
env: { ...run.context.env },
|
|
38
|
+
vars: { ...run.context.vars },
|
|
39
|
+
node: { id: node.id, ...(node.key && { key: node.key }), ...(node.title && { title: node.title }) },
|
|
40
|
+
signal: run.signal,
|
|
41
|
+
sleep: (ms) => run.sleep(ms, run.signal),
|
|
42
|
+
log: (level, text) => run.log(node.id, level, text),
|
|
43
|
+
}),
|
|
44
|
+
stopped,
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (run.signal.aborted)
|
|
49
|
+
return "cancelled";
|
|
50
|
+
return fail(e);
|
|
51
|
+
}
|
|
52
|
+
if (result === "stopped" || run.signal.aborted)
|
|
53
|
+
return "cancelled";
|
|
54
|
+
const declared = new Set((step.outputs ?? []).map((o) => o.name));
|
|
55
|
+
const extra = keysOf(result?.outputs).filter((k) => !declared.has(k));
|
|
56
|
+
if (extra.length)
|
|
57
|
+
return { status: "failed", error: `${step.label} returned outputs it doesn't declare: ${extra.join(", ")}.` };
|
|
58
|
+
const allowed = step.writesVar ? getPath(typed, step.writesVar) : undefined;
|
|
59
|
+
const wrongVars = keysOf(result?.vars).filter((k) => k !== allowed);
|
|
60
|
+
if (wrongVars.length)
|
|
61
|
+
return { status: "failed", error: `${step.label} wrote variables it doesn't declare: ${wrongVars.join(", ")}.` };
|
|
62
|
+
Object.assign(run.context.vars, result?.vars);
|
|
63
|
+
return {
|
|
64
|
+
status: "passed",
|
|
65
|
+
...(keysOf(result?.outputs).length > 0 && { outputs: result.outputs }),
|
|
66
|
+
...(keysOf(result?.vars).length > 0 && { wrote: result.vars }),
|
|
67
|
+
...(result?.note && { note: result.note }),
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Flow steps: define your own, and the built-in ones. Node only. */
|
|
2
|
+
export { defineStep, stepNode, type RunConfigOf, type StepDefinition, type StepResult, type StepRunInput, } from "./define.js";
|
|
3
|
+
export { stepHandler } from "./handler.js";
|
|
4
|
+
export { STEPS, STEP_HANDLERS, STEP_NODES, listSteps, stepCatalog, validateSteps, } from "./catalog.js";
|
|
5
|
+
export type { StepField, StepInfo, StepOutput, StepSection } from "../core/index.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Flow steps: define your own, and the built-in ones. Node only. */
|
|
2
|
+
export { defineStep, stepNode, } from "./define.js";
|
|
3
|
+
export { stepHandler } from "./handler.js";
|
|
4
|
+
export { STEPS, STEP_HANDLERS, STEP_NODES, listSteps, stepCatalog, validateSteps, } from "./catalog.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const setVariable: import("./define.js").StepDefinition<import("./define.js").RunConfigOf<readonly [{
|
|
2
|
+
readonly path: "config.name";
|
|
3
|
+
readonly type: "text";
|
|
4
|
+
readonly label: "Name";
|
|
5
|
+
readonly placeholder: "orderId";
|
|
6
|
+
readonly default: "";
|
|
7
|
+
readonly required: true;
|
|
8
|
+
readonly pattern: "^[a-z][a-z0-9_]*$";
|
|
9
|
+
readonly patternMessage: "use lowercase letters, digits and _ for the name.";
|
|
10
|
+
}, {
|
|
11
|
+
readonly path: "config.value";
|
|
12
|
+
readonly type: "expression";
|
|
13
|
+
readonly label: "Value";
|
|
14
|
+
readonly placeholder: "\"order-\" + run.environment";
|
|
15
|
+
readonly default: "";
|
|
16
|
+
}]>>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineStep } from "./define.js";
|
|
2
|
+
export const setVariable = defineStep({
|
|
3
|
+
kind: "set-variable",
|
|
4
|
+
label: "Set variable",
|
|
5
|
+
icon: "braces",
|
|
6
|
+
section: "control",
|
|
7
|
+
fields: [
|
|
8
|
+
{
|
|
9
|
+
path: "config.name", type: "text", label: "Name", placeholder: "orderId", default: "",
|
|
10
|
+
required: true, pattern: "^[a-z][a-z0-9_]*$",
|
|
11
|
+
patternMessage: "use lowercase letters, digits and _ for the name.",
|
|
12
|
+
},
|
|
13
|
+
{ path: "config.value", type: "expression", label: "Value", placeholder: '"order-" + run.environment', default: "" },
|
|
14
|
+
],
|
|
15
|
+
summary: "vars.{config.name} = {config.value}",
|
|
16
|
+
writesVar: "config.name",
|
|
17
|
+
async run({ config }) {
|
|
18
|
+
return { vars: { [config.name]: config.value } };
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const wait: import("./define.js").StepDefinition<import("./define.js").RunConfigOf<readonly [{
|
|
2
|
+
readonly path: "config.ms";
|
|
3
|
+
readonly type: "number";
|
|
4
|
+
readonly label: "Duration";
|
|
5
|
+
readonly unit: "ms";
|
|
6
|
+
readonly default: 1000;
|
|
7
|
+
readonly min: 0;
|
|
8
|
+
}]>>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineStep } from "./define.js";
|
|
2
|
+
export const wait = defineStep({
|
|
3
|
+
kind: "wait",
|
|
4
|
+
label: "Wait",
|
|
5
|
+
icon: "timer",
|
|
6
|
+
section: "control",
|
|
7
|
+
fields: [
|
|
8
|
+
{ path: "config.ms", type: "number", label: "Duration", unit: "ms", default: 1000, min: 0 },
|
|
9
|
+
],
|
|
10
|
+
summary: "{config.ms} ms",
|
|
11
|
+
async run({ config, sleep }) {
|
|
12
|
+
await sleep(config.ms);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -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;
|