@itmetsam/samurai-framework 0.19.0 → 0.21.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/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/capabilities.d.ts +16 -3
- package/dist/capabilities.js +16 -3
- 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/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/index.d.ts +1 -0
- package/dist/flows/run/index.js +1 -0
- package/dist/flows/run/node-folder.d.ts +10 -0
- package/dist/flows/run/node-folder.js +32 -0
- 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.js +2 -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 +9 -0
- package/dist/recorder/toolbar/actions/save-output.js +46 -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 +14 -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 +20 -3
- package/dist/recorder/toolbar/source/events-page.js +2 -1
- package/dist/recorder/toolbar/source/fields.js +8 -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 +6 -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 +50 -10
- package/dist/recorder/toolbar/source/views-rail.js +5 -2
- package/dist/recorder/toolbar/source.d.ts +5 -5
- package/dist/recorder/toolbar/source.js +5 -5
- package/dist/recorder/toolbar/types.d.ts +10 -3
- 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 +1 -1
|
@@ -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
|
};
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
|
-
export function forkContext(context) {
|
|
2
|
+
export function forkContext(context, outputsById) {
|
|
3
3
|
return {
|
|
4
4
|
context: {
|
|
5
5
|
...context,
|
|
6
6
|
vars: { ...context.vars },
|
|
7
7
|
nodes: { ...context.nodes },
|
|
8
8
|
},
|
|
9
|
+
outputsById: { ...outputsById },
|
|
9
10
|
base: { ...context.vars },
|
|
10
11
|
};
|
|
11
12
|
}
|
|
12
13
|
/**
|
|
13
|
-
* Takes what the branches wrote into `into`: every node output, and every variable a branch changed. A variable
|
|
14
|
+
* Takes what the branches wrote into `into` and `intoOutputs`: every node output, and every variable a branch changed. A variable
|
|
14
15
|
* two branches changed to different values is a clash and is not taken; the first clash is returned.
|
|
15
16
|
*/
|
|
16
|
-
export function mergeForks(into, forks) {
|
|
17
|
+
export function mergeForks(into, intoOutputs, forks) {
|
|
17
18
|
const changed = new Map();
|
|
18
19
|
const clashes = new Set();
|
|
19
|
-
for (const { context, base } of forks) {
|
|
20
|
+
for (const { context, outputsById, base } of forks) {
|
|
20
21
|
Object.assign(into.nodes, context.nodes);
|
|
22
|
+
Object.assign(intoOutputs, outputsById);
|
|
21
23
|
for (const [name, value] of Object.entries(context.vars)) {
|
|
22
24
|
if (name in base && isDeepStrictEqual(base[name], value))
|
|
23
25
|
continue;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RunEvent } from "../../runner/reporter.js";
|
|
2
|
+
import type { SupportedBrowser } from "../../types/browser.js";
|
|
2
3
|
export type NodeStatus = "passed" | "failed" | "skipped" | "not-run";
|
|
3
4
|
export interface NodeResult {
|
|
4
5
|
status: NodeStatus;
|
|
@@ -10,32 +11,46 @@ export interface NodeResult {
|
|
|
10
11
|
branch?: string;
|
|
11
12
|
/** What the node wrote to vars */
|
|
12
13
|
wrote?: Record<string, unknown>;
|
|
13
|
-
/** A step's outputs, readable later as `nodes.<key>.<name>` */
|
|
14
|
+
/** A step's outputs, or the outputs a Test node's test saved; readable later as `nodes.<key>.<name>` */
|
|
14
15
|
outputs?: Record<string, unknown>;
|
|
16
|
+
/** A Test or Group node: the input values it sent to its tests; absent when it sent none */
|
|
17
|
+
inputs?: Record<string, string>;
|
|
15
18
|
}
|
|
16
19
|
/** One test of a node: absolute file and full name */
|
|
17
20
|
export interface NodeTest {
|
|
18
21
|
file: string;
|
|
19
22
|
name: string;
|
|
20
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* What a flow run reports. A flow runs once per browser: every event but the last carries the browser of its run,
|
|
26
|
+
* `flow-start` and `flow-end` come once per browser, and `flow-done` closes the whole thing.
|
|
27
|
+
*/
|
|
21
28
|
export type FlowEvent = {
|
|
22
29
|
type: "flow-start";
|
|
30
|
+
browser: SupportedBrowser;
|
|
23
31
|
environment: string;
|
|
24
32
|
} | {
|
|
25
33
|
type: "node-start";
|
|
34
|
+
browser: SupportedBrowser;
|
|
26
35
|
nodeId: string;
|
|
27
36
|
} | {
|
|
28
37
|
type: "node-end";
|
|
38
|
+
browser: SupportedBrowser;
|
|
29
39
|
nodeId: string;
|
|
30
40
|
result: NodeResult;
|
|
31
41
|
} | {
|
|
32
42
|
type: "test";
|
|
43
|
+
browser: SupportedBrowser;
|
|
33
44
|
nodeId: string;
|
|
34
45
|
event: RunEvent;
|
|
35
46
|
} | {
|
|
36
47
|
type: "flow-end";
|
|
48
|
+
browser: SupportedBrowser;
|
|
37
49
|
summary: FlowSummary;
|
|
38
|
-
}
|
|
50
|
+
} | ({
|
|
51
|
+
type: "flow-done";
|
|
52
|
+
} & FlowRunResult);
|
|
53
|
+
/** How one browser's walk of a flow ended */
|
|
39
54
|
export interface FlowSummary {
|
|
40
55
|
status: "passed" | "failed" | "cancelled";
|
|
41
56
|
nodes: Record<string, NodeResult>;
|
|
@@ -43,3 +58,16 @@ export interface FlowSummary {
|
|
|
43
58
|
/** The flow couldn't be walked at all */
|
|
44
59
|
error?: string;
|
|
45
60
|
}
|
|
61
|
+
/** One browser's run of a flow */
|
|
62
|
+
export interface FlowBrowserRun {
|
|
63
|
+
browser: SupportedBrowser;
|
|
64
|
+
summary: FlowSummary;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* How a flow run ended, across its browsers: `cancelled` when it was aborted, else `failed` when any run failed,
|
|
68
|
+
* else `passed`. `runs` holds the runs that started, in the order they ran; an abort leaves the rest out
|
|
69
|
+
*/
|
|
70
|
+
export interface FlowRunResult {
|
|
71
|
+
status: "passed" | "failed" | "cancelled";
|
|
72
|
+
runs: FlowBrowserRun[];
|
|
73
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runWiredTests } from "../node-inputs.js";
|
|
2
|
+
import { notInBrowser } from "./support.js";
|
|
2
3
|
export const group = async (node, run) => {
|
|
4
|
+
const skipped = notInBrowser(node, run);
|
|
5
|
+
if (skipped)
|
|
6
|
+
return skipped;
|
|
3
7
|
const list = run.nodeTests[node.id] ?? [];
|
|
4
8
|
if (list.length === 0)
|
|
5
9
|
return { status: "passed", note: "This group has no tests." };
|
|
6
|
-
return
|
|
10
|
+
return runWiredTests(node, run, list);
|
|
7
11
|
};
|
|
@@ -2,7 +2,12 @@ import { type FlowNodeModel } from "../../core/index.js";
|
|
|
2
2
|
import type { NodeTest } from "../events.js";
|
|
3
3
|
import type { HandlerRun, Outcome } from "./types.js";
|
|
4
4
|
export declare const fail: (e: unknown) => Outcome;
|
|
5
|
-
/**
|
|
6
|
-
export declare
|
|
5
|
+
/** A Test or Group node whose browsers lack the run's is skipped with a note */
|
|
6
|
+
export declare const notInBrowser: (node: FlowNodeModel, run: HandlerRun) => Outcome | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Runs `list` for the node with `inputs` and turns the result into an outcome: the inputs it sent (when any) and,
|
|
9
|
+
* for a Test node that passed, the outputs its test saved (a retried test's last attempt)
|
|
10
|
+
*/
|
|
11
|
+
export declare function runNodeTests(node: FlowNodeModel, run: HandlerRun, list: NodeTest[], inputs?: Record<string, string>): Promise<Outcome>;
|
|
7
12
|
/** The checker's problem with an expression, as the builder would show it: a bypassed check fails the node the same way */
|
|
8
13
|
export declare const problemAt: (node: FlowNodeModel, run: HandlerRun, text: string) => string | undefined;
|
|
@@ -3,20 +3,49 @@ export const fail = (e) => ({
|
|
|
3
3
|
status: "failed",
|
|
4
4
|
error: e.message,
|
|
5
5
|
});
|
|
6
|
-
/**
|
|
7
|
-
export
|
|
6
|
+
/** A Test or Group node whose browsers lack the run's is skipped with a note */
|
|
7
|
+
export const notInBrowser = (node, run) => run.nodeBrowsers?.[node.id]?.includes(run.browser) === false
|
|
8
|
+
? { status: "skipped", note: `not run in ${run.browser}` }
|
|
9
|
+
: undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Runs `list` for the node with `inputs` and turns the result into an outcome: the inputs it sent (when any) and,
|
|
12
|
+
* for a Test node that passed, the outputs its test saved (a retried test's last attempt)
|
|
13
|
+
*/
|
|
14
|
+
export async function runNodeTests(node, run, list, inputs = {}) {
|
|
15
|
+
const sent = Object.keys(inputs).length > 0 ? inputs : undefined;
|
|
16
|
+
let saved;
|
|
8
17
|
let result;
|
|
9
18
|
try {
|
|
10
|
-
result = await run.runTests(node.id, list, (e) =>
|
|
19
|
+
result = await run.runTests(node.id, list, (e) => {
|
|
20
|
+
if (e.type === "test-end")
|
|
21
|
+
saved = "outputs" in e.result ? e.result.outputs : undefined;
|
|
22
|
+
run.onTestEvent(node.id, e);
|
|
23
|
+
}, sent);
|
|
11
24
|
}
|
|
12
25
|
catch (e) {
|
|
13
|
-
return
|
|
26
|
+
return {
|
|
27
|
+
status: "failed",
|
|
28
|
+
error: e.message,
|
|
29
|
+
...(sent && { inputs: sent }),
|
|
30
|
+
};
|
|
14
31
|
}
|
|
15
32
|
if (result === "cancelled")
|
|
16
33
|
return "cancelled";
|
|
34
|
+
const outputs = node.kind === "test" ? saved : undefined;
|
|
17
35
|
return result === "passed"
|
|
18
|
-
? {
|
|
19
|
-
|
|
36
|
+
? {
|
|
37
|
+
status: "passed",
|
|
38
|
+
...(sent && { inputs: sent }),
|
|
39
|
+
...(outputs && { outputs }),
|
|
40
|
+
}
|
|
41
|
+
: {
|
|
42
|
+
status: "failed",
|
|
43
|
+
error: "A test failed.",
|
|
44
|
+
...(sent && { inputs: sent }),
|
|
45
|
+
};
|
|
20
46
|
}
|
|
21
47
|
/** The checker's problem with an expression, as the builder would show it: a bypassed check fails the node the same way */
|
|
22
|
-
export const problemAt = (node, run, text) => checkExpression(parseExpression(text), scopeAt(run.flow, node.id, run.environment,
|
|
48
|
+
export const problemAt = (node, run, text) => checkExpression(parseExpression(text), scopeAt(run.flow, node.id, run.environment, {
|
|
49
|
+
nodes: run.nodes,
|
|
50
|
+
...(run.facts && { facts: run.facts }),
|
|
51
|
+
}))?.message;
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runWiredTests } from "../node-inputs.js";
|
|
2
|
+
import { notInBrowser } from "./support.js";
|
|
2
3
|
export const test = async (node, run) => {
|
|
4
|
+
const skipped = notInBrowser(node, run);
|
|
5
|
+
if (skipped)
|
|
6
|
+
return skipped;
|
|
3
7
|
const list = run.nodeTests[node.id];
|
|
4
8
|
if (!list || list.length === 0)
|
|
5
9
|
return { status: "failed", error: "This test was deleted or moved." };
|
|
6
|
-
return
|
|
10
|
+
return runWiredTests(node, run, list);
|
|
7
11
|
};
|