@itmetsam/samurai-framework 0.20.0 → 0.21.1
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 +2 -2
- package/dist/api.d.ts +1 -1
- package/dist/assert/assertion-error.d.ts +3 -1
- package/dist/assert/assertion-error.js +2 -2
- package/dist/assert/locator-assertions.d.ts +35 -4
- package/dist/assert/locator-assertions.js +56 -28
- package/dist/assert/page-assertions.js +1 -1
- package/dist/assert/pending-assertions.d.ts +3 -2
- package/dist/assert/pending-assertions.js +5 -4
- package/dist/assert/text-match.d.ts +6 -0
- package/dist/assert/text-match.js +12 -0
- package/dist/browser/browser.js +19 -3
- package/dist/browser/chrome-launch.d.ts +2 -0
- package/dist/browser/chrome-launch.js +5 -0
- package/dist/capabilities.d.ts +16 -5
- package/dist/capabilities.js +16 -5
- package/dist/cli/flow-reporter.d.ts +1 -1
- package/dist/cli/flow-reporter.js +2 -2
- package/dist/cli/flow.js +4 -4
- package/dist/config/mask.d.ts +6 -2
- package/dist/config/mask.js +4 -1
- package/dist/flows/core/check-io.d.ts +23 -0
- package/dist/flows/core/check-io.js +84 -0
- package/dist/flows/core/check.d.ts +16 -2
- package/dist/flows/core/check.js +20 -4
- package/dist/flows/core/expression/scope.d.ts +9 -2
- package/dist/flows/core/expression/scope.js +12 -4
- package/dist/flows/core/index.d.ts +2 -1
- package/dist/flows/core/index.js +2 -1
- package/dist/flows/core/nodes/condition.js +0 -1
- package/dist/flows/core/nodes/define.d.ts +0 -2
- package/dist/flows/core/nodes/group.js +0 -1
- package/dist/flows/core/nodes/test.js +0 -1
- package/dist/flows/core/schema.d.ts +10 -5
- package/dist/flows/core/schema.js +29 -1
- package/dist/flows/core/steps.d.ts +7 -0
- package/dist/flows/core/steps.js +15 -0
- package/dist/flows/core/wiring.d.ts +72 -0
- package/dist/flows/core/wiring.js +151 -0
- package/dist/flows/run/branch-context.d.ts +10 -4
- package/dist/flows/run/branch-context.js +6 -4
- package/dist/flows/run/events.d.ts +30 -2
- package/dist/flows/run/handlers/group.js +6 -2
- package/dist/flows/run/handlers/support.d.ts +7 -2
- package/dist/flows/run/handlers/support.js +36 -7
- package/dist/flows/run/handlers/test.js +6 -2
- package/dist/flows/run/handlers/types.d.ts +11 -3
- package/dist/flows/run/node-folder.d.ts +8 -3
- package/dist/flows/run/node-folder.js +28 -4
- package/dist/flows/run/node-inputs.d.ts +5 -0
- package/dist/flows/run/node-inputs.js +62 -0
- package/dist/flows/run/node-pool.d.ts +2 -2
- package/dist/flows/run/node-pool.js +6 -6
- package/dist/flows/run/node-runner.d.ts +24 -0
- package/dist/flows/run/node-runner.js +48 -0
- package/dist/flows/run/per-browser.d.ts +21 -0
- package/dist/flows/run/per-browser.js +46 -0
- package/dist/flows/run/project-facts.d.ts +13 -0
- package/dist/flows/run/project-facts.js +12 -0
- package/dist/flows/run/project-options.d.ts +9 -2
- package/dist/flows/run/project.d.ts +13 -8
- package/dist/flows/run/project.js +48 -51
- package/dist/flows/run/report.d.ts +7 -7
- package/dist/flows/run/report.js +3 -5
- package/dist/flows/run/run.d.ts +21 -7
- package/dist/flows/run/run.js +21 -11
- package/dist/flows/run/walker.d.ts +23 -7
- package/dist/flows/run/walker.js +18 -7
- package/dist/locator/locator.d.ts +3 -1
- package/dist/locator/locator.js +6 -0
- package/dist/recorder/apply.js +17 -7
- package/dist/recorder/normalise.d.ts +18 -3
- package/dist/recorder/normalise.js +18 -3
- package/dist/recorder/session-secrets.js +4 -0
- package/dist/recorder/toolbar/action-guard.d.ts +2 -5
- package/dist/recorder/toolbar/action-guard.js +9 -5
- package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
- package/dist/recorder/toolbar/actions/insert.js +43 -7
- package/dist/recorder/toolbar/actions/pick.d.ts +3 -0
- package/dist/recorder/toolbar/actions/pick.js +12 -0
- package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
- package/dist/recorder/toolbar/actions/reply.js +9 -0
- package/dist/recorder/toolbar/actions/save-output.d.ts +15 -0
- package/dist/recorder/toolbar/actions/save-output.js +73 -0
- package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
- package/dist/recorder/toolbar/controller-fixture.js +2 -0
- package/dist/recorder/toolbar/controller.d.ts +2 -1
- package/dist/recorder/toolbar/controller.js +16 -7
- package/dist/recorder/toolbar/insertables.d.ts +7 -0
- package/dist/recorder/toolbar/insertables.js +12 -0
- package/dist/recorder/toolbar/inspect.js +3 -1
- package/dist/recorder/toolbar/protocol.d.ts +43 -3
- package/dist/recorder/toolbar/source/events-page.js +2 -1
- package/dist/recorder/toolbar/source/fields.js +11 -0
- package/dist/recorder/toolbar/source/handlers.js +19 -12
- package/dist/recorder/toolbar/source/picking.js +22 -7
- package/dist/recorder/toolbar/source/recorder-side.js +14 -0
- package/dist/recorder/toolbar/source/setup.js +3 -3
- package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
- package/dist/recorder/toolbar/source/views-panels.js +62 -10
- package/dist/recorder/toolbar/source/views-rail.js +5 -2
- package/dist/recorder/toolbar/source.d.ts +6 -5
- package/dist/recorder/toolbar/source.js +6 -5
- package/dist/recorder/toolbar/types.d.ts +12 -5
- package/dist/runner/options.d.ts +5 -0
- package/dist/runner/pooled-run.d.ts +3 -2
- package/dist/runner/pooled-run.js +4 -2
- package/dist/runner/registry.d.ts +7 -3
- package/dist/runner/registry.js +29 -11
- package/dist/runner/reporter.d.ts +4 -0
- package/dist/runner/reporter.js +2 -0
- package/dist/runner/run-test.js +23 -2
- package/dist/runner/run.d.ts +7 -0
- package/dist/runner/run.js +8 -1
- package/dist/runner/test-io.d.ts +30 -0
- package/dist/runner/test-io.js +111 -0
- package/dist/runner/test-runner.js +1 -1
- package/dist/runner/worker-protocol.d.ts +2 -0
- package/dist/runner/worker-session.js +1 -0
- package/dist/runner/worker.d.ts +1 -0
- package/dist/runner/worker.js +4 -1
- package/dist/steps/edit.d.ts +14 -3
- package/dist/steps/edit.js +65 -23
- package/dist/steps/emit.d.ts +2 -0
- package/dist/steps/emit.js +32 -8
- package/dist/steps/index.d.ts +1 -1
- package/dist/steps/model.d.ts +42 -2
- package/dist/steps/parse/expectation.d.ts +8 -1
- package/dist/steps/parse/expectation.js +17 -11
- package/dist/steps/parse/step.js +64 -8
- package/dist/steps/parse.js +25 -16
- package/dist/steps/test-options.d.ts +25 -0
- package/dist/steps/test-options.js +158 -0
- package/dist/steps/text.d.ts +7 -0
- package/dist/steps/text.js +12 -0
- package/dist/types/test.d.ts +34 -0
- package/dist/wait/output-sink.d.ts +24 -0
- package/dist/wait/output-sink.js +59 -0
- package/package.json +44 -4
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { problemOf, scopeAt } from "./expression/index.js";
|
|
2
|
+
import { meetOf, reachOrder } from "./graph.js";
|
|
3
|
+
import { isInputSource } from "./schema.js";
|
|
4
|
+
import { declaredInputs, declaredOutputs, wiringAt, } from "./wiring.js";
|
|
5
|
+
/**
|
|
6
|
+
* Problems with inputs and outputs: an input with no source, an override expression
|
|
7
|
+
* that doesn't check out, an entry for an input the test doesn't declare, a Group
|
|
8
|
+
* node with an expression, and two branches of one Parallel saving the same output.
|
|
9
|
+
*/
|
|
10
|
+
export function ioProblems(flow, check) {
|
|
11
|
+
return [
|
|
12
|
+
...flow.nodes.flatMap((n) => n.kind === "test" || n.kind === "group"
|
|
13
|
+
? inputProblems(flow, n, check)
|
|
14
|
+
: []),
|
|
15
|
+
...flow.nodes
|
|
16
|
+
.filter((n) => n.kind === "parallel")
|
|
17
|
+
.flatMap((p) => branchClashes(flow, p, check)),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
function inputProblems(flow, node, check) {
|
|
21
|
+
const known = node.kind === "test"
|
|
22
|
+
? !!node.ref?.testId && !!check.facts.tests[node.ref.testId]
|
|
23
|
+
: !!node.ref?.group && !!check.facts.groups[node.ref.group];
|
|
24
|
+
if (!known)
|
|
25
|
+
return [];
|
|
26
|
+
const problems = [];
|
|
27
|
+
const at = (level, message) => problems.push({ level, nodeId: node.id, message });
|
|
28
|
+
const declared = declaredInputs(node, check.facts);
|
|
29
|
+
const groupSet = new Set();
|
|
30
|
+
for (const [name, source] of Object.entries(node.inputs ?? {})) {
|
|
31
|
+
if (!declared.has(name))
|
|
32
|
+
at("warning", `input "${name}" is not used by this ${node.kind === "group" ? "node" : "test"}`);
|
|
33
|
+
else if (!isInputSource(source) || !("expression" in source))
|
|
34
|
+
continue;
|
|
35
|
+
else if (node.kind === "group")
|
|
36
|
+
groupSet.add(name);
|
|
37
|
+
else if (check.environment) {
|
|
38
|
+
const problem = problemOf(source.expression, scopeAt(flow, node.id, check.environment, {
|
|
39
|
+
nodes: check.nodes,
|
|
40
|
+
facts: check.facts,
|
|
41
|
+
}));
|
|
42
|
+
if (problem)
|
|
43
|
+
at("error", `${check.titleOf(node)}: input "${name}": ${problem.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (groupSet.size)
|
|
47
|
+
at("error", `${check.titleOf(node)}: Group nodes can't set inputs (${[...groupSet].map((x) => `"${x}"`).join(", ")})`);
|
|
48
|
+
for (const [name, wiring] of Object.entries(wiringAt(flow, node.id, { facts: check.facts })))
|
|
49
|
+
if (wiring.from === "none" && !groupSet.has(name))
|
|
50
|
+
at("error", `${check.titleOf(node)}: input "${name}" has no source. Save it as an output in an earlier test, set an expression, or use the default`);
|
|
51
|
+
return problems;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `<parallel title>: branches both save "x"`, once per output name two branches
|
|
55
|
+
* save. A node two branches both reach before the meet counts for the first only.
|
|
56
|
+
*/
|
|
57
|
+
function branchClashes(flow, parallel, check) {
|
|
58
|
+
const meet = meetOf(flow, parallel.id);
|
|
59
|
+
const beyond = new Set(meet ? reachOrder(flow, meet) : []);
|
|
60
|
+
const savedBy = new Map();
|
|
61
|
+
const counted = new Set();
|
|
62
|
+
for (const e of flow.edges.filter((x) => x.source === parallel.id)) {
|
|
63
|
+
const names = new Set(reachOrder(flow, e.target)
|
|
64
|
+
.filter((id) => {
|
|
65
|
+
if (beyond.has(id) || counted.has(id))
|
|
66
|
+
return false;
|
|
67
|
+
counted.add(id);
|
|
68
|
+
return true;
|
|
69
|
+
})
|
|
70
|
+
.flatMap((id) => {
|
|
71
|
+
const n = flow.nodes.find((x) => x.id === id);
|
|
72
|
+
return n ? declaredOutputs(n, check.facts) : [];
|
|
73
|
+
}));
|
|
74
|
+
for (const name of names)
|
|
75
|
+
savedBy.set(name, (savedBy.get(name) ?? 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
return [...savedBy]
|
|
78
|
+
.filter(([, count]) => count > 1)
|
|
79
|
+
.map(([name]) => ({
|
|
80
|
+
level: "error",
|
|
81
|
+
nodeId: parallel.id,
|
|
82
|
+
message: `${check.titleOf(parallel)}: branches both save "${name}"`,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FlowProblem } from "./nodes/index.js";
|
|
2
2
|
import type { AnyNodeDefinition } from "./nodes/define.js";
|
|
3
3
|
import type { FlowFile, FlowNodeModel } from "./schema.js";
|
|
4
|
+
import type { FlowFacts } from "./wiring.js";
|
|
4
5
|
export type { FlowProblem } from "./nodes/index.js";
|
|
5
6
|
export interface CheckOptions {
|
|
6
7
|
/** The test ids that exist. When given, a Test node whose test is gone is an error. */
|
|
@@ -30,6 +31,12 @@ export interface CheckOptions {
|
|
|
30
31
|
titles?: (node: FlowNodeModel) => string | undefined;
|
|
31
32
|
/** Definitions of kinds core doesn't have (steps). A node of a kind in neither is "Unknown step". */
|
|
32
33
|
nodes?: readonly AnyNodeDefinition[];
|
|
34
|
+
/**
|
|
35
|
+
* The project's tests' inputs and outputs and its groups' tests. When given, every
|
|
36
|
+
* Test and Group node's inputs are checked (a source for each, override expressions,
|
|
37
|
+
* unused entries) and so are a Parallel's branches (two saving one output name).
|
|
38
|
+
*/
|
|
39
|
+
facts?: FlowFacts;
|
|
33
40
|
}
|
|
34
41
|
/**
|
|
35
42
|
* Checks a flow before a run: the shape of the graph, keys, references that still
|
|
@@ -38,8 +45,15 @@ export interface CheckOptions {
|
|
|
38
45
|
* its key, else its kind's label (the app names it by what the editor shows).
|
|
39
46
|
*/
|
|
40
47
|
export declare function checkFlow(flow: FlowFile, options?: CheckOptions): FlowProblem[];
|
|
41
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Every expression field that doesn't check out, as "Label: message". `options.nodes`
|
|
50
|
+
* adds definitions core doesn't have (steps); `options.facts` lets expressions read
|
|
51
|
+
* a keyed Test node's declared outputs (`nodes.<key>.<output>`).
|
|
52
|
+
*/
|
|
42
53
|
export declare function expressionProblems(flow: FlowFile, environment: {
|
|
43
54
|
name: string;
|
|
44
55
|
variables: Record<string, unknown>;
|
|
45
|
-
},
|
|
56
|
+
}, options?: {
|
|
57
|
+
nodes?: readonly AnyNodeDefinition[];
|
|
58
|
+
facts?: FlowFacts;
|
|
59
|
+
}): FlowProblem[];
|
package/dist/flows/core/check.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ioProblems } from "./check-io.js";
|
|
1
2
|
import { problemOf, scopeAt } from "./expression/index.js";
|
|
2
3
|
import { KEY_PATTERN } from "./keys.js";
|
|
3
4
|
import { branchNames, dynamicOf, getPath, nodeDef, withDefaults, } from "./nodes/index.js";
|
|
@@ -142,7 +143,17 @@ export function checkFlow(flow, options = {}) {
|
|
|
142
143
|
}
|
|
143
144
|
problems.push(...emptyExpressions(flow, defOf));
|
|
144
145
|
if (options.environment)
|
|
145
|
-
problems.push(...expressionProblems(flow, options.environment,
|
|
146
|
+
problems.push(...expressionProblems(flow, options.environment, {
|
|
147
|
+
nodes: options.nodes,
|
|
148
|
+
facts: options.facts,
|
|
149
|
+
}));
|
|
150
|
+
if (options.facts)
|
|
151
|
+
problems.push(...ioProblems(flow, {
|
|
152
|
+
facts: options.facts,
|
|
153
|
+
environment: options.environment,
|
|
154
|
+
nodes: options.nodes,
|
|
155
|
+
titleOf: (n) => options.titles?.(n) ?? titleOf(n, defOf(n.kind)),
|
|
156
|
+
}));
|
|
146
157
|
const nodeCount = flow.nodes.filter((n) => {
|
|
147
158
|
const s = shapeOf(n);
|
|
148
159
|
return s !== "start" && s !== "end";
|
|
@@ -177,8 +188,13 @@ function emptyExpressions(flow, defOf) {
|
|
|
177
188
|
}));
|
|
178
189
|
});
|
|
179
190
|
}
|
|
180
|
-
/**
|
|
181
|
-
|
|
191
|
+
/**
|
|
192
|
+
* Every expression field that doesn't check out, as "Label: message". `options.nodes`
|
|
193
|
+
* adds definitions core doesn't have (steps); `options.facts` lets expressions read
|
|
194
|
+
* a keyed Test node's declared outputs (`nodes.<key>.<output>`).
|
|
195
|
+
*/
|
|
196
|
+
export function expressionProblems(flow, environment, options = {}) {
|
|
197
|
+
const { nodes = [], facts } = options;
|
|
182
198
|
const defOf = definitions(nodes);
|
|
183
199
|
return flow.nodes.flatMap((n) => {
|
|
184
200
|
const def = defOf(n.kind);
|
|
@@ -190,7 +206,7 @@ export function expressionProblems(flow, environment, nodes = []) {
|
|
|
190
206
|
const text = getPath(n, f.path);
|
|
191
207
|
if (typeof text !== "string" || text.trim() === "")
|
|
192
208
|
return [];
|
|
193
|
-
const problem = problemOf(text, scopeAt(flow, n.id, environment, nodes));
|
|
209
|
+
const problem = problemOf(text, scopeAt(flow, n.id, environment, { nodes, facts }));
|
|
194
210
|
return problem
|
|
195
211
|
? [
|
|
196
212
|
{
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { AnyNodeDefinition } from "../nodes/define.js";
|
|
2
2
|
import type { FlowFile } from "../schema.js";
|
|
3
|
+
import { type FlowFacts } from "../wiring.js";
|
|
3
4
|
import type { ExpressionScope } from "./check.js";
|
|
4
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* What an expression at `nodeId` may use. `options.nodes` adds definitions core
|
|
7
|
+
* doesn't have (steps); `options.facts` adds each keyed Test node's declared outputs.
|
|
8
|
+
*/
|
|
5
9
|
export declare function scopeAt(flow: FlowFile, nodeId: string, environment: {
|
|
6
10
|
name: string;
|
|
7
11
|
variables: Record<string, unknown>;
|
|
8
|
-
},
|
|
12
|
+
}, options?: {
|
|
13
|
+
nodes?: readonly AnyNodeDefinition[];
|
|
14
|
+
facts?: FlowFacts;
|
|
15
|
+
}): ExpressionScope;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { ancestors, dominates, reachOrder } from "../graph.js";
|
|
2
2
|
import { NODES } from "../nodes/index.js";
|
|
3
3
|
import { getPath } from "../nodes/path.js";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { declaredOutputs } from "../wiring.js";
|
|
5
|
+
/**
|
|
6
|
+
* What an expression at `nodeId` may use. `options.nodes` adds definitions core
|
|
7
|
+
* doesn't have (steps); `options.facts` adds each keyed Test node's declared outputs.
|
|
8
|
+
*/
|
|
9
|
+
export function scopeAt(flow, nodeId, environment, options = {}) {
|
|
10
|
+
const { nodes = [], facts } = options;
|
|
6
11
|
const defOf = (kind) => nodes.find((d) => d.kind === kind) ?? NODES.find((d) => d.kind === kind);
|
|
7
12
|
const keyOf = (id) => flow.nodes.find((n) => n.id === id)?.key;
|
|
8
13
|
const keys = (ids) => [...ids].map(keyOf).filter((k) => !!k);
|
|
@@ -17,8 +22,11 @@ export function scopeAt(flow, nodeId, environment, nodes = []) {
|
|
|
17
22
|
})
|
|
18
23
|
.filter((name) => typeof name === "string" && name !== "");
|
|
19
24
|
const outputs = Object.fromEntries(flow.nodes.flatMap((n) => {
|
|
20
|
-
const results =
|
|
21
|
-
|
|
25
|
+
const results = [
|
|
26
|
+
...(defOf(n.kind)?.results ?? []),
|
|
27
|
+
...declaredOutputs(n, facts),
|
|
28
|
+
];
|
|
29
|
+
return n.key && results.length ? [[n.key, results]] : [];
|
|
22
30
|
}));
|
|
23
31
|
return {
|
|
24
32
|
env: {
|
|
@@ -8,10 +8,11 @@ export * from "./graph.js";
|
|
|
8
8
|
export * from "./expression/index.js";
|
|
9
9
|
export * from "./nodes/index.js";
|
|
10
10
|
export * from "./check.js";
|
|
11
|
+
export * from "./wiring.js";
|
|
11
12
|
export * from "./steps.js";
|
|
12
13
|
/**
|
|
13
14
|
* Version of the contract between the SAMURAI app and this framework.
|
|
14
15
|
* Increments whenever the app and the framework must agree on something new
|
|
15
16
|
* (a node kind, a field, an event), so the app can tell when the framework is too old.
|
|
16
17
|
*/
|
|
17
|
-
export declare const FLOWS_API =
|
|
18
|
+
export declare const FLOWS_API = 3;
|
package/dist/flows/core/index.js
CHANGED
|
@@ -8,10 +8,11 @@ export * from "./graph.js";
|
|
|
8
8
|
export * from "./expression/index.js";
|
|
9
9
|
export * from "./nodes/index.js";
|
|
10
10
|
export * from "./check.js";
|
|
11
|
+
export * from "./wiring.js";
|
|
11
12
|
export * from "./steps.js";
|
|
12
13
|
/**
|
|
13
14
|
* Version of the contract between the SAMURAI app and this framework.
|
|
14
15
|
* Increments whenever the app and the framework must agree on something new
|
|
15
16
|
* (a node kind, a field, an event), so the app can tell when the framework is too old.
|
|
16
17
|
*/
|
|
17
|
-
export const FLOWS_API =
|
|
18
|
+
export const FLOWS_API = 3;
|
|
@@ -108,8 +108,6 @@ export interface NodeCoreDefinition<K extends string = string, C = Record<string
|
|
|
108
108
|
*/
|
|
109
109
|
branches?: readonly Branch[] | DynamicBranches;
|
|
110
110
|
fields: readonly Field[];
|
|
111
|
-
/** The node can write outputs (shared-context variables) */
|
|
112
|
-
outputs?: boolean;
|
|
113
111
|
/** A step's output names: readable later as `nodes.<key>.<name>` */
|
|
114
112
|
results?: readonly string[];
|
|
115
113
|
/** The path of the field holding the name of a variable the node writes (`vars.<name>`) */
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}
|
|
1
|
+
/** Where a Test or Group node's input comes from, when not wired by name. A Group node accepts only `{ useDefault: true }` */
|
|
2
|
+
export type InputSource = {
|
|
3
|
+
expression: string;
|
|
4
|
+
} | {
|
|
5
|
+
useDefault: true;
|
|
6
|
+
};
|
|
7
|
+
/** Whether `value` is an `InputSource`: `{ expression: string }` or `{ useDefault: true }` */
|
|
8
|
+
export declare function isInputSource(value: unknown): value is InputSource;
|
|
5
9
|
export interface FlowNodeModel {
|
|
6
10
|
id: string;
|
|
7
11
|
/** Short name expressions use: nodes.<key>. Lowercase letters, digits and _, unique in the flow */
|
|
@@ -15,7 +19,8 @@ export interface FlowNodeModel {
|
|
|
15
19
|
testId?: string;
|
|
16
20
|
group?: string;
|
|
17
21
|
};
|
|
18
|
-
|
|
22
|
+
/** test and group: per input name, an override or "use the default"; the rest is wired by name (see wiringAt) */
|
|
23
|
+
inputs?: Record<string, InputSource>;
|
|
19
24
|
/** What to do when the node fails; "stop" ends the run there */
|
|
20
25
|
onFailure: "stop" | "continue";
|
|
21
26
|
/** Continue in the previous node's browser session (kept in the file; the framework opens a fresh browser per test today) */
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/** Whether `value` is an `InputSource`: `{ expression: string }` or `{ useDefault: true }` */
|
|
2
|
+
export function isInputSource(value) {
|
|
3
|
+
if (typeof value !== "object" || value === null)
|
|
4
|
+
return false;
|
|
5
|
+
const source = value;
|
|
6
|
+
return typeof source.expression === "string" || source.useDefault === true;
|
|
7
|
+
}
|
|
1
8
|
/** The flow file format version this framework writes and reads */
|
|
2
9
|
export const FLOW_VERSION = 1;
|
|
3
10
|
/** Steps, in order, that lift an older file to FLOW_VERSION. Empty while version 1 is the only one. */
|
|
@@ -29,5 +36,26 @@ export function readFlowFile(raw) {
|
|
|
29
36
|
if (step)
|
|
30
37
|
flow = step.migrate(flow);
|
|
31
38
|
}
|
|
32
|
-
return {
|
|
39
|
+
return {
|
|
40
|
+
...flow,
|
|
41
|
+
nodes: flow.nodes.map(readNode),
|
|
42
|
+
version: FLOW_VERSION,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A node as the file holds it, without its hand-typed `outputs` (removed; tests declare their outputs now) and
|
|
47
|
+
* without `inputs` entries that aren't an `InputSource`
|
|
48
|
+
*/
|
|
49
|
+
function readNode(node) {
|
|
50
|
+
if (typeof node !== "object" || node === null)
|
|
51
|
+
return node;
|
|
52
|
+
const rest = { ...node };
|
|
53
|
+
delete rest.outputs;
|
|
54
|
+
if ("inputs" in rest) {
|
|
55
|
+
const inputs = rest.inputs;
|
|
56
|
+
delete rest.inputs;
|
|
57
|
+
if (typeof inputs === "object" && inputs !== null && !Array.isArray(inputs))
|
|
58
|
+
rest.inputs = Object.fromEntries(Object.entries(inputs).filter(([, source]) => isInputSource(source)));
|
|
59
|
+
}
|
|
60
|
+
return rest;
|
|
33
61
|
}
|
|
@@ -36,6 +36,13 @@ export interface StepInfo {
|
|
|
36
36
|
export declare const STEP_KIND: RegExp;
|
|
37
37
|
/** A node's own result fields: an output can't take these names */
|
|
38
38
|
export declare const RESERVED_OUTPUTS: readonly string[];
|
|
39
|
+
/** The shape of an input or output name */
|
|
40
|
+
export declare const IO_NAME: RegExp;
|
|
41
|
+
/**
|
|
42
|
+
* Whether `name` can name a test's input or output: an identifier that isn't in `RESERVED_OUTPUTS` and isn't one
|
|
43
|
+
* of an object's special keys
|
|
44
|
+
*/
|
|
45
|
+
export declare const isIoName: (name: string) => boolean;
|
|
39
46
|
/** Problems with `node`'s values against the fields' rules, as "Label: message" */
|
|
40
47
|
export declare function fieldProblems(fields: readonly StepField[], node: FlowNodeModel, label: string): FlowProblem[];
|
|
41
48
|
/** The definition core's walker and checkFlow use for a step; `validate` is the step's own (engine only) */
|
package/dist/flows/core/steps.js
CHANGED
|
@@ -6,6 +6,21 @@ export const RESERVED_OUTPUTS = [
|
|
|
6
6
|
"durationMs",
|
|
7
7
|
"error",
|
|
8
8
|
];
|
|
9
|
+
/** The shape of an input or output name */
|
|
10
|
+
export const IO_NAME = /^[A-Za-z_]\w*$/;
|
|
11
|
+
/** Keys an object literal or a lookup treats specially: as a name they would set or read the prototype */
|
|
12
|
+
const OBJECT_KEYS = [
|
|
13
|
+
"__proto__",
|
|
14
|
+
"constructor",
|
|
15
|
+
"prototype",
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Whether `name` can name a test's input or output: an identifier that isn't in `RESERVED_OUTPUTS` and isn't one
|
|
19
|
+
* of an object's special keys
|
|
20
|
+
*/
|
|
21
|
+
export const isIoName = (name) => IO_NAME.test(name) &&
|
|
22
|
+
!RESERVED_OUTPUTS.includes(name) &&
|
|
23
|
+
!OBJECT_KEYS.includes(name);
|
|
9
24
|
const TITLE_FIELDS = [
|
|
10
25
|
{ path: "title", type: "text", label: "Title" },
|
|
11
26
|
{ path: "subtitle", type: "text", label: "Description" },
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type FlowFile, type FlowNodeModel } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a flow needs to know about the project's tests and groups to wire inputs:
|
|
4
|
+
* each test's declared inputs (with their defaults) and outputs, by test id, and
|
|
5
|
+
* each group's test ids, by group name. `checkFlowInProject` builds it from the
|
|
6
|
+
* project; the SAMURAI app builds it from `listTests` / `listGroups`.
|
|
7
|
+
*/
|
|
8
|
+
export interface FlowFacts {
|
|
9
|
+
tests: Record<string, {
|
|
10
|
+
inputs: {
|
|
11
|
+
name: string;
|
|
12
|
+
default: string;
|
|
13
|
+
}[];
|
|
14
|
+
outputs: string[];
|
|
15
|
+
}>;
|
|
16
|
+
groups: Record<string, string[]>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The facts for a project's tests and groups, as `listTests` and `listGroups` give them: a test's `id`, its
|
|
20
|
+
* `inputs` and `outputs` (absent when it declares none) and a group's `name` and `testIds`. The lists are copied.
|
|
21
|
+
*/
|
|
22
|
+
export declare function flowFactsFrom(tests: readonly {
|
|
23
|
+
id: string;
|
|
24
|
+
inputs?: readonly {
|
|
25
|
+
name: string;
|
|
26
|
+
default: string;
|
|
27
|
+
}[];
|
|
28
|
+
outputs?: readonly string[];
|
|
29
|
+
}[], groups: readonly {
|
|
30
|
+
name: string;
|
|
31
|
+
testIds: readonly string[];
|
|
32
|
+
}[]): FlowFacts;
|
|
33
|
+
/** Where one input of a node gets its value. Computed, never stored */
|
|
34
|
+
export type InputWiring = {
|
|
35
|
+
from: "node";
|
|
36
|
+
nodeId: string;
|
|
37
|
+
output: string;
|
|
38
|
+
} | {
|
|
39
|
+
from: "expression";
|
|
40
|
+
expression: string;
|
|
41
|
+
} | {
|
|
42
|
+
from: "default";
|
|
43
|
+
value: string;
|
|
44
|
+
} | {
|
|
45
|
+
from: "none";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Whether node `d` always ran before node `n`. Directly: `d` dominates `n`, or `d`
|
|
49
|
+
* sits on a Parallel branch that every path from the Parallel into the branch's end
|
|
50
|
+
* passes, and the Parallel's meet dominates `n`. Through nested Parallels: `d`
|
|
51
|
+
* always ran before some Parallel's meet, which always ran before `n`. A Parallel
|
|
52
|
+
* runs all its branches; a Condition runs one arm, so a node on one arm never
|
|
53
|
+
* always ran.
|
|
54
|
+
*/
|
|
55
|
+
export declare function alwaysBefore(flow: FlowFile, d: string, n: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* A Test or Group node's declared inputs, with their defaults. A group's inputs
|
|
58
|
+
* are the union of its tests' inputs; the first default wins. Empty without facts
|
|
59
|
+
* or for a test or group the facts don't know.
|
|
60
|
+
*/
|
|
61
|
+
export declare function declaredInputs(node: FlowNodeModel, facts: FlowFacts | undefined): Map<string, string>;
|
|
62
|
+
/** The outputs a Test node declares; none for any other node */
|
|
63
|
+
export declare const declaredOutputs: (node: FlowNodeModel, facts: FlowFacts | undefined) => readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* Where each declared input of `nodeId` comes from, in this order: the node's
|
|
66
|
+
* override expression (Test nodes only), its "use the default", the closest Test
|
|
67
|
+
* node that always ran before it and declares an output of that name (of two in a
|
|
68
|
+
* row, the later one), else none.
|
|
69
|
+
*/
|
|
70
|
+
export declare function wiringAt(flow: FlowFile, nodeId: string, options?: {
|
|
71
|
+
facts?: FlowFacts;
|
|
72
|
+
}): Record<string, InputWiring>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { dominates, meetOf, reachOrder } from "./graph.js";
|
|
2
|
+
import { isInputSource } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* The facts for a project's tests and groups, as `listTests` and `listGroups` give them: a test's `id`, its
|
|
5
|
+
* `inputs` and `outputs` (absent when it declares none) and a group's `name` and `testIds`. The lists are copied.
|
|
6
|
+
*/
|
|
7
|
+
export function flowFactsFrom(tests, groups) {
|
|
8
|
+
return {
|
|
9
|
+
tests: Object.fromEntries(tests.map((t) => [
|
|
10
|
+
t.id,
|
|
11
|
+
{
|
|
12
|
+
inputs: (t.inputs ?? []).map(({ name, default: value }) => ({
|
|
13
|
+
name,
|
|
14
|
+
default: value,
|
|
15
|
+
})),
|
|
16
|
+
outputs: [...(t.outputs ?? [])],
|
|
17
|
+
},
|
|
18
|
+
])),
|
|
19
|
+
groups: Object.fromEntries(groups.map((g) => [g.name, [...g.testIds]])),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether node `d` always ran before node `n`. Directly: `d` dominates `n`, or `d`
|
|
24
|
+
* sits on a Parallel branch that every path from the Parallel into the branch's end
|
|
25
|
+
* passes, and the Parallel's meet dominates `n`. Through nested Parallels: `d`
|
|
26
|
+
* always ran before some Parallel's meet, which always ran before `n`. A Parallel
|
|
27
|
+
* runs all its branches; a Condition runs one arm, so a node on one arm never
|
|
28
|
+
* always ran.
|
|
29
|
+
*/
|
|
30
|
+
export function alwaysBefore(flow, d, n) {
|
|
31
|
+
return before(flow, parallelMeets(flow), d, n, new Set());
|
|
32
|
+
}
|
|
33
|
+
/** alwaysBefore over `meets`; `path` holds the meets on the current chain, so a looping flow still ends */
|
|
34
|
+
function before(flow, meets, d, n, path) {
|
|
35
|
+
if (d === n)
|
|
36
|
+
return false;
|
|
37
|
+
if (directlyBefore(flow, d, n))
|
|
38
|
+
return true;
|
|
39
|
+
return meets.some((meet) => {
|
|
40
|
+
if (meet === n || path.has(meet) || !directlyBefore(flow, d, meet))
|
|
41
|
+
return false;
|
|
42
|
+
path.add(meet);
|
|
43
|
+
const ran = before(flow, meets, meet, n, path);
|
|
44
|
+
path.delete(meet);
|
|
45
|
+
return ran;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** The meet of every Parallel in the flow */
|
|
49
|
+
const parallelMeets = (flow) => flow.nodes
|
|
50
|
+
.filter((p) => p.kind === "parallel")
|
|
51
|
+
.map((p) => meetOf(flow, p.id))
|
|
52
|
+
.filter((m) => !!m);
|
|
53
|
+
/** `d` dominates `n`, or `d` is on a Parallel branch every path into its meet passes, and the meet dominates `n` */
|
|
54
|
+
function directlyBefore(flow, d, n) {
|
|
55
|
+
if (dominates(flow, d, n))
|
|
56
|
+
return d !== n;
|
|
57
|
+
return flow.nodes.some((p) => {
|
|
58
|
+
if (p.kind !== "parallel")
|
|
59
|
+
return false;
|
|
60
|
+
const meet = meetOf(flow, p.id);
|
|
61
|
+
if (!meet || !dominates(flow, meet, n))
|
|
62
|
+
return false;
|
|
63
|
+
return flow.edges.some((e) => e.source === p.id && passesBefore(flow, e.target, d, meet));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** Whether every path from `from` to `meet` passes `d` (and one does) */
|
|
67
|
+
function passesBefore(flow, from, d, meet) {
|
|
68
|
+
if (from === d)
|
|
69
|
+
return d !== meet;
|
|
70
|
+
if (!reachOrder(flow, from).includes(d))
|
|
71
|
+
return false;
|
|
72
|
+
const seen = new Set([d]);
|
|
73
|
+
const stack = [from];
|
|
74
|
+
while (stack.length) {
|
|
75
|
+
const id = stack.pop();
|
|
76
|
+
if (id === meet)
|
|
77
|
+
return false;
|
|
78
|
+
if (seen.has(id))
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(id);
|
|
81
|
+
for (const e of flow.edges)
|
|
82
|
+
if (e.source === id)
|
|
83
|
+
stack.push(e.target);
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A Test or Group node's declared inputs, with their defaults. A group's inputs
|
|
89
|
+
* are the union of its tests' inputs; the first default wins. Empty without facts
|
|
90
|
+
* or for a test or group the facts don't know.
|
|
91
|
+
*/
|
|
92
|
+
export function declaredInputs(node, facts) {
|
|
93
|
+
const inputs = new Map();
|
|
94
|
+
if (!facts)
|
|
95
|
+
return inputs;
|
|
96
|
+
const testIds = node.kind === "test"
|
|
97
|
+
? [node.ref?.testId]
|
|
98
|
+
: node.kind === "group"
|
|
99
|
+
? (facts.groups[node.ref?.group ?? ""] ?? [])
|
|
100
|
+
: [];
|
|
101
|
+
for (const id of testIds)
|
|
102
|
+
for (const input of (id && facts.tests[id]?.inputs) || [])
|
|
103
|
+
if (!inputs.has(input.name))
|
|
104
|
+
inputs.set(input.name, input.default);
|
|
105
|
+
return inputs;
|
|
106
|
+
}
|
|
107
|
+
/** The outputs a Test node declares; none for any other node */
|
|
108
|
+
export const declaredOutputs = (node, facts) => (node.kind === "test" &&
|
|
109
|
+
node.ref?.testId &&
|
|
110
|
+
facts?.tests[node.ref.testId]?.outputs) ||
|
|
111
|
+
[];
|
|
112
|
+
/**
|
|
113
|
+
* Where each declared input of `nodeId` comes from, in this order: the node's
|
|
114
|
+
* override expression (Test nodes only), its "use the default", the closest Test
|
|
115
|
+
* node that always ran before it and declares an output of that name (of two in a
|
|
116
|
+
* row, the later one), else none.
|
|
117
|
+
*/
|
|
118
|
+
export function wiringAt(flow, nodeId, options = {}) {
|
|
119
|
+
const node = flow.nodes.find((n) => n.id === nodeId);
|
|
120
|
+
if (!node)
|
|
121
|
+
return {};
|
|
122
|
+
const wiring = {};
|
|
123
|
+
let producers;
|
|
124
|
+
for (const [name, value] of declaredInputs(node, options.facts)) {
|
|
125
|
+
// A flow built in memory may hold anything here: only an InputSource is read
|
|
126
|
+
const entry = node.inputs?.[name];
|
|
127
|
+
const source = isInputSource(entry) ? entry : undefined;
|
|
128
|
+
if (source && "expression" in source && node.kind === "test")
|
|
129
|
+
wiring[name] = { from: "expression", expression: source.expression };
|
|
130
|
+
else if (source && "useDefault" in source)
|
|
131
|
+
wiring[name] = { from: "default", value };
|
|
132
|
+
else {
|
|
133
|
+
producers ??= flow.nodes.filter((p) => declaredOutputs(p, options.facts).length > 0 &&
|
|
134
|
+
alwaysBefore(flow, p.id, nodeId));
|
|
135
|
+
const from = closest(flow, producers.filter((p) => declaredOutputs(p, options.facts).includes(name)));
|
|
136
|
+
wiring[name] = from
|
|
137
|
+
? { from: "node", nodeId: from.id, output: name }
|
|
138
|
+
: { from: "none" };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return wiring;
|
|
142
|
+
}
|
|
143
|
+
/** The candidate no other candidate always ran after; on a tie, the last a walk from Start meets */
|
|
144
|
+
function closest(flow, candidates) {
|
|
145
|
+
const latest = candidates.filter((c) => !candidates.some((o) => o !== c && alwaysBefore(flow, c.id, o.id)));
|
|
146
|
+
if (latest.length <= 1)
|
|
147
|
+
return latest[0];
|
|
148
|
+
const start = flow.nodes.find((n) => n.kind === "start");
|
|
149
|
+
const order = start ? reachOrder(flow, start.id) : [];
|
|
150
|
+
return latest.sort((a, b) => order.indexOf(b.id) - order.indexOf(a.id))[0];
|
|
151
|
+
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { ExpressionContext } from "../core/index.js";
|
|
2
|
-
/**
|
|
2
|
+
/** The outputs each node saved in a run, by node id: what later nodes' inputs are wired from */
|
|
3
|
+
export type OutputsById = Record<string, Record<string, unknown>>;
|
|
4
|
+
/**
|
|
5
|
+
* A branch of a Parallel node: its own copy of the variables and node outputs (by key and by id), and the
|
|
6
|
+
* variables as they were when it forked
|
|
7
|
+
*/
|
|
3
8
|
export interface Fork {
|
|
4
9
|
context: ExpressionContext;
|
|
10
|
+
outputsById: OutputsById;
|
|
5
11
|
base: Record<string, unknown>;
|
|
6
12
|
}
|
|
7
|
-
export declare function forkContext(context: ExpressionContext): Fork;
|
|
13
|
+
export declare function forkContext(context: ExpressionContext, outputsById: OutputsById): Fork;
|
|
8
14
|
/**
|
|
9
|
-
* Takes what the branches wrote into `into`: every node output, and every variable a branch changed. A variable
|
|
15
|
+
* Takes what the branches wrote into `into` and `intoOutputs`: every node output, and every variable a branch changed. A variable
|
|
10
16
|
* two branches changed to different values is a clash and is not taken; the first clash is returned.
|
|
11
17
|
*/
|
|
12
|
-
export declare function mergeForks(into: ExpressionContext, forks: readonly Fork[]): {
|
|
18
|
+
export declare function mergeForks(into: ExpressionContext, intoOutputs: OutputsById, forks: readonly Fork[]): {
|
|
13
19
|
clash?: string;
|
|
14
20
|
};
|