@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.
Files changed (139) hide show
  1. package/README.md +2 -2
  2. package/dist/api.d.ts +1 -1
  3. package/dist/assert/assertion-error.d.ts +3 -1
  4. package/dist/assert/assertion-error.js +2 -2
  5. package/dist/assert/locator-assertions.d.ts +35 -4
  6. package/dist/assert/locator-assertions.js +56 -28
  7. package/dist/assert/page-assertions.js +1 -1
  8. package/dist/assert/pending-assertions.d.ts +3 -2
  9. package/dist/assert/pending-assertions.js +5 -4
  10. package/dist/assert/text-match.d.ts +6 -0
  11. package/dist/assert/text-match.js +12 -0
  12. package/dist/browser/browser.js +19 -3
  13. package/dist/browser/chrome-launch.d.ts +2 -0
  14. package/dist/browser/chrome-launch.js +5 -0
  15. package/dist/capabilities.d.ts +16 -5
  16. package/dist/capabilities.js +16 -5
  17. package/dist/cli/flow-reporter.d.ts +1 -1
  18. package/dist/cli/flow-reporter.js +2 -2
  19. package/dist/cli/flow.js +4 -4
  20. package/dist/config/mask.d.ts +6 -2
  21. package/dist/config/mask.js +4 -1
  22. package/dist/flows/core/check-io.d.ts +23 -0
  23. package/dist/flows/core/check-io.js +84 -0
  24. package/dist/flows/core/check.d.ts +16 -2
  25. package/dist/flows/core/check.js +20 -4
  26. package/dist/flows/core/expression/scope.d.ts +9 -2
  27. package/dist/flows/core/expression/scope.js +12 -4
  28. package/dist/flows/core/index.d.ts +2 -1
  29. package/dist/flows/core/index.js +2 -1
  30. package/dist/flows/core/nodes/condition.js +0 -1
  31. package/dist/flows/core/nodes/define.d.ts +0 -2
  32. package/dist/flows/core/nodes/group.js +0 -1
  33. package/dist/flows/core/nodes/test.js +0 -1
  34. package/dist/flows/core/schema.d.ts +10 -5
  35. package/dist/flows/core/schema.js +29 -1
  36. package/dist/flows/core/steps.d.ts +7 -0
  37. package/dist/flows/core/steps.js +15 -0
  38. package/dist/flows/core/wiring.d.ts +72 -0
  39. package/dist/flows/core/wiring.js +151 -0
  40. package/dist/flows/run/branch-context.d.ts +10 -4
  41. package/dist/flows/run/branch-context.js +6 -4
  42. package/dist/flows/run/events.d.ts +30 -2
  43. package/dist/flows/run/handlers/group.js +6 -2
  44. package/dist/flows/run/handlers/support.d.ts +7 -2
  45. package/dist/flows/run/handlers/support.js +36 -7
  46. package/dist/flows/run/handlers/test.js +6 -2
  47. package/dist/flows/run/handlers/types.d.ts +11 -3
  48. package/dist/flows/run/node-folder.d.ts +8 -3
  49. package/dist/flows/run/node-folder.js +28 -4
  50. package/dist/flows/run/node-inputs.d.ts +5 -0
  51. package/dist/flows/run/node-inputs.js +62 -0
  52. package/dist/flows/run/node-pool.d.ts +2 -2
  53. package/dist/flows/run/node-pool.js +6 -6
  54. package/dist/flows/run/node-runner.d.ts +24 -0
  55. package/dist/flows/run/node-runner.js +48 -0
  56. package/dist/flows/run/per-browser.d.ts +21 -0
  57. package/dist/flows/run/per-browser.js +46 -0
  58. package/dist/flows/run/project-facts.d.ts +13 -0
  59. package/dist/flows/run/project-facts.js +12 -0
  60. package/dist/flows/run/project-options.d.ts +9 -2
  61. package/dist/flows/run/project.d.ts +13 -8
  62. package/dist/flows/run/project.js +48 -51
  63. package/dist/flows/run/report.d.ts +7 -7
  64. package/dist/flows/run/report.js +3 -5
  65. package/dist/flows/run/run.d.ts +21 -7
  66. package/dist/flows/run/run.js +21 -11
  67. package/dist/flows/run/walker.d.ts +23 -7
  68. package/dist/flows/run/walker.js +18 -7
  69. package/dist/locator/locator.d.ts +3 -1
  70. package/dist/locator/locator.js +6 -0
  71. package/dist/recorder/apply.js +17 -7
  72. package/dist/recorder/normalise.d.ts +18 -3
  73. package/dist/recorder/normalise.js +18 -3
  74. package/dist/recorder/session-secrets.js +4 -0
  75. package/dist/recorder/toolbar/action-guard.d.ts +2 -5
  76. package/dist/recorder/toolbar/action-guard.js +9 -5
  77. package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
  78. package/dist/recorder/toolbar/actions/insert.js +43 -7
  79. package/dist/recorder/toolbar/actions/pick.d.ts +3 -0
  80. package/dist/recorder/toolbar/actions/pick.js +12 -0
  81. package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
  82. package/dist/recorder/toolbar/actions/reply.js +9 -0
  83. package/dist/recorder/toolbar/actions/save-output.d.ts +15 -0
  84. package/dist/recorder/toolbar/actions/save-output.js +73 -0
  85. package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
  86. package/dist/recorder/toolbar/controller-fixture.js +2 -0
  87. package/dist/recorder/toolbar/controller.d.ts +2 -1
  88. package/dist/recorder/toolbar/controller.js +16 -7
  89. package/dist/recorder/toolbar/insertables.d.ts +7 -0
  90. package/dist/recorder/toolbar/insertables.js +12 -0
  91. package/dist/recorder/toolbar/inspect.js +3 -1
  92. package/dist/recorder/toolbar/protocol.d.ts +43 -3
  93. package/dist/recorder/toolbar/source/events-page.js +2 -1
  94. package/dist/recorder/toolbar/source/fields.js +11 -0
  95. package/dist/recorder/toolbar/source/handlers.js +19 -12
  96. package/dist/recorder/toolbar/source/picking.js +22 -7
  97. package/dist/recorder/toolbar/source/recorder-side.js +14 -0
  98. package/dist/recorder/toolbar/source/setup.js +3 -3
  99. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  100. package/dist/recorder/toolbar/source/views-panels.js +62 -10
  101. package/dist/recorder/toolbar/source/views-rail.js +5 -2
  102. package/dist/recorder/toolbar/source.d.ts +6 -5
  103. package/dist/recorder/toolbar/source.js +6 -5
  104. package/dist/recorder/toolbar/types.d.ts +12 -5
  105. package/dist/runner/options.d.ts +5 -0
  106. package/dist/runner/pooled-run.d.ts +3 -2
  107. package/dist/runner/pooled-run.js +4 -2
  108. package/dist/runner/registry.d.ts +7 -3
  109. package/dist/runner/registry.js +29 -11
  110. package/dist/runner/reporter.d.ts +4 -0
  111. package/dist/runner/reporter.js +2 -0
  112. package/dist/runner/run-test.js +23 -2
  113. package/dist/runner/run.d.ts +7 -0
  114. package/dist/runner/run.js +8 -1
  115. package/dist/runner/test-io.d.ts +30 -0
  116. package/dist/runner/test-io.js +111 -0
  117. package/dist/runner/test-runner.js +1 -1
  118. package/dist/runner/worker-protocol.d.ts +2 -0
  119. package/dist/runner/worker-session.js +1 -0
  120. package/dist/runner/worker.d.ts +1 -0
  121. package/dist/runner/worker.js +4 -1
  122. package/dist/steps/edit.d.ts +14 -3
  123. package/dist/steps/edit.js +65 -23
  124. package/dist/steps/emit.d.ts +2 -0
  125. package/dist/steps/emit.js +32 -8
  126. package/dist/steps/index.d.ts +1 -1
  127. package/dist/steps/model.d.ts +42 -2
  128. package/dist/steps/parse/expectation.d.ts +8 -1
  129. package/dist/steps/parse/expectation.js +17 -11
  130. package/dist/steps/parse/step.js +64 -8
  131. package/dist/steps/parse.js +25 -16
  132. package/dist/steps/test-options.d.ts +25 -0
  133. package/dist/steps/test-options.js +158 -0
  134. package/dist/steps/text.d.ts +7 -0
  135. package/dist/steps/text.js +12 -0
  136. package/dist/types/test.d.ts +34 -0
  137. package/dist/wait/output-sink.d.ts +24 -0
  138. package/dist/wait/output-sink.js +59 -0
  139. 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
- /** Every expression field that doesn't check out, as "Label: message" */
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
- }, nodes?: readonly AnyNodeDefinition[]): FlowProblem[];
56
+ }, options?: {
57
+ nodes?: readonly AnyNodeDefinition[];
58
+ facts?: FlowFacts;
59
+ }): FlowProblem[];
@@ -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, options.nodes));
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
- /** Every expression field that doesn't check out, as "Label: message" */
181
- export function expressionProblems(flow, environment, nodes = []) {
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
- /** What an expression at `nodeId` may use; `nodes` adds definitions core doesn't have (steps) */
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
- }, nodes?: readonly AnyNodeDefinition[]): ExpressionScope;
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
- /** What an expression at `nodeId` may use; `nodes` adds definitions core doesn't have (steps) */
5
- export function scopeAt(flow, nodeId, environment, nodes = []) {
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 = defOf(n.kind)?.results;
21
- return n.key && results?.length ? [[n.key, results]] : [];
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 = 2;
18
+ export declare const FLOWS_API = 3;
@@ -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 = 2;
18
+ export const FLOWS_API = 3;
@@ -16,6 +16,5 @@ export const condition = defineNode({
16
16
  default: "",
17
17
  },
18
18
  ],
19
- outputs: true,
20
19
  onFailure: true,
21
20
  });
@@ -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>`) */
@@ -5,7 +5,6 @@ export const group = defineNode({
5
5
  label: "Group",
6
6
  shape: "step",
7
7
  fields: [{ path: "ref.group", type: "group", label: "Group" }],
8
- outputs: true,
9
8
  validate: (node, ctx) => ctx.groups && !(node.ref?.group && ctx.groups.has(node.ref.group))
10
9
  ? [
11
10
  {
@@ -5,7 +5,6 @@ export const test = defineNode({
5
5
  label: "Test",
6
6
  shape: "step",
7
7
  fields: [],
8
- outputs: true,
9
8
  validate: (node, ctx) => ctx.tests && !(node.ref?.testId && ctx.tests.has(node.ref.testId))
10
9
  ? [
11
10
  {
@@ -1,7 +1,11 @@
1
- export interface FlowNodeOutput {
2
- /** Name of the shared-context variable the node writes */
3
- name: string;
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
- outputs?: FlowNodeOutput[];
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 { ...flow, version: FLOW_VERSION };
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) */
@@ -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
- /** A branch of a Parallel node: its own copy of the variables and node outputs, and the variables as they were when it forked */
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
  };