@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
@@ -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 { runNodeTests } from "./support.js";
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 runNodeTests(node, run, list);
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
- /** Runs `list` for the node and turns the result into an outcome */
6
- export declare function runNodeTests(node: FlowNodeModel, run: HandlerRun, list: NodeTest[]): Promise<Outcome>;
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
- /** Runs `list` for the node and turns the result into an outcome */
7
- export async function runNodeTests(node, run, list) {
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) => run.onTestEvent(node.id, e), run.nodeBrowsers?.[node.id]);
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 fail(e);
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
- ? { status: "passed" }
19
- : { status: "failed", error: "A test failed." };
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, run.nodes))?.message;
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 { runNodeTests } from "./support.js";
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 runNodeTests(node, run, list);
10
+ return runWiredTests(node, run, list);
7
11
  };
@@ -1,4 +1,5 @@
1
- import type { AnyNodeDefinition, ExpressionContext, FlowFile, FlowNodeModel } from "../../core/index.js";
1
+ import type { AnyNodeDefinition, ExpressionContext, FlowFacts, FlowFile, FlowNodeModel } from "../../core/index.js";
2
+ import type { OutputsById } from "../branch-context.js";
2
3
  import type { RunEvent } from "../../../runner/reporter.js";
3
4
  import type { SupportedBrowser } from "../../../types/browser.js";
4
5
  import type { NodeResult, NodeTest } from "../events.js";
@@ -10,11 +11,18 @@ export interface HandlerRun {
10
11
  variables: Record<string, unknown>;
11
12
  };
12
13
  context: ExpressionContext;
14
+ /** The outputs the nodes that ran saved, by node id: where wired inputs come from */
15
+ outputsById: OutputsById;
16
+ /** The project's tests' inputs and outputs and its groups' tests; without them no input is wired */
17
+ facts?: FlowFacts;
13
18
  nodeTests: Record<string, NodeTest[]>;
14
- /** The browsers of the nodes that have their own (a Group node's group); the others use the project's */
19
+ /** The browser this run is in: a flow runs once per browser */
20
+ browser: SupportedBrowser;
21
+ /** The browsers each node runs in; a node not listed runs in every one */
15
22
  nodeBrowsers?: Record<string, SupportedBrowser[]>;
16
23
  signal: AbortSignal;
17
- runTests: (nodeId: string, tests: NodeTest[], onEvent: (e: RunEvent) => void, browsers?: SupportedBrowser[]) => Promise<"passed" | "failed" | "cancelled">;
24
+ /** Runs a node's tests in `browser`, with values for their inputs */
25
+ runTests: (nodeId: string, tests: NodeTest[], onEvent: (e: RunEvent) => void, inputs?: Record<string, string>) => Promise<"passed" | "failed" | "cancelled">;
18
26
  sleep: (ms: number, signal: AbortSignal) => Promise<void>;
19
27
  onTestEvent: (nodeId: string, e: RunEvent) => void;
20
28
  /** Definitions of kinds core doesn't have (steps), for expression scopes */
@@ -1,5 +1,10 @@
1
+ import type { RunEvent } from "../../runner/reporter.js";
2
+ import type { SupportedBrowser } from "../../types/browser.js";
1
3
  /**
2
- * The folder a flow node's screenshots go in, relative to `runFlowInProject`'s `outputDir`, with `/` separators:
3
- * `nodes/` and the node id cleaned for a file name. A node's screenshot paths are relative to this folder.
4
+ * The folder a flow node writes to in one browser's run, relative to `runFlowInProject`'s `outputDir`, with `/`
5
+ * separators: the browser, `nodes/` and the node id cleaned for a file name. A node's screenshot paths already
6
+ * start with it (they are relative to `outputDir`), so an app needs this only to show or open the folder.
4
7
  */
5
- export declare function nodeFolder(nodeId: string): string;
8
+ export declare function nodeFolder(nodeId: string, browser: SupportedBrowser): string;
9
+ /** A node's test event with its screenshot paths (relative to the node's folder) made relative to `outputDir` */
10
+ export declare function inNodeFolder(event: RunEvent, folder: string): RunEvent;
@@ -1,8 +1,32 @@
1
1
  import { cleanName } from "../../browser/evidence.js";
2
2
  /**
3
- * The folder a flow node's screenshots go in, relative to `runFlowInProject`'s `outputDir`, with `/` separators:
4
- * `nodes/` and the node id cleaned for a file name. A node's screenshot paths are relative to this folder.
3
+ * The folder a flow node writes to in one browser's run, relative to `runFlowInProject`'s `outputDir`, with `/`
4
+ * separators: the browser, `nodes/` and the node id cleaned for a file name. A node's screenshot paths already
5
+ * start with it (they are relative to `outputDir`), so an app needs this only to show or open the folder.
5
6
  */
6
- export function nodeFolder(nodeId) {
7
- return `nodes/${cleanName(nodeId)}`;
7
+ export function nodeFolder(nodeId, browser) {
8
+ return `${browser}/nodes/${cleanName(nodeId)}`;
9
+ }
10
+ const withFolder = (result, folder) => "screenshots" in result && result.screenshots
11
+ ? {
12
+ ...result,
13
+ screenshots: result.screenshots.map((s) => ({
14
+ ...s,
15
+ path: `${folder}/${s.path}`,
16
+ })),
17
+ }
18
+ : result;
19
+ /** A node's test event with its screenshot paths (relative to the node's folder) made relative to `outputDir` */
20
+ export function inNodeFolder(event, folder) {
21
+ if (event.type === "test-end")
22
+ return { ...event, result: withFolder(event.result, folder) };
23
+ if (event.type === "run-end")
24
+ return {
25
+ ...event,
26
+ summary: {
27
+ ...event.summary,
28
+ tests: event.summary.tests.map((t) => withFolder(t, folder)),
29
+ },
30
+ };
31
+ return event;
8
32
  }
@@ -0,0 +1,5 @@
1
+ import { type FlowNodeModel } from "../core/index.js";
2
+ import type { NodeTest } from "./events.js";
3
+ import type { HandlerRun, Outcome } from "./handlers/types.js";
4
+ /** Runs a Test or Group node's tests with the inputs it resolves; an input that can't be resolved fails the node */
5
+ export declare function runWiredTests(node: FlowNodeModel, run: HandlerRun, list: NodeTest[]): Promise<Outcome>;
@@ -0,0 +1,62 @@
1
+ import { evaluateText, wiringAt } from "../core/index.js";
2
+ import { problemAt, runNodeTests } from "./handlers/support.js";
3
+ /** What a node's title is in a message: its title, else its key, else its id */
4
+ const titleOf = (node, id) => node?.title || node?.key || id;
5
+ /** The value of an override expression as an input: text as is, a number or a boolean as text, else an error */
6
+ function expressionValue(node, run, expression) {
7
+ const problem = problemAt(node, run, expression);
8
+ if (problem)
9
+ return { error: problem };
10
+ let value;
11
+ try {
12
+ value = evaluateText(expression, run.context);
13
+ }
14
+ catch (e) {
15
+ return { error: e.message };
16
+ }
17
+ if (typeof value === "string")
18
+ return { value };
19
+ if (typeof value === "number" || typeof value === "boolean")
20
+ return { value: String(value) };
21
+ return { error: "the expression gave no text" };
22
+ }
23
+ /**
24
+ * The values a Test or Group node sends for its inputs, by `wiringAt`: an earlier node's saved output, an override
25
+ * expression's value, nothing for "use the default". An input whose source saved nothing, an override that gives
26
+ * no text, or an input with no source is an error, and the node's tests must not run.
27
+ */
28
+ function resolveNodeInputs(node, run) {
29
+ const inputs = {};
30
+ const wiring = wiringAt(run.flow, node.id, {
31
+ ...(run.facts && { facts: run.facts }),
32
+ });
33
+ for (const [name, source] of Object.entries(wiring)) {
34
+ if (source.from === "default")
35
+ continue;
36
+ if (source.from === "none")
37
+ return { error: `input "${name}" has no source` };
38
+ if (source.from === "node") {
39
+ const value = run.outputsById[source.nodeId]?.[source.output];
40
+ if (typeof value !== "string") {
41
+ const producer = run.flow.nodes.find((n) => n.id === source.nodeId);
42
+ return {
43
+ error: `input "${name}": ${titleOf(producer, source.nodeId)} saved no ${source.output}`,
44
+ };
45
+ }
46
+ inputs[name] = value;
47
+ continue;
48
+ }
49
+ const result = expressionValue(node, run, source.expression);
50
+ if ("error" in result)
51
+ return { error: `input "${name}": ${result.error}` };
52
+ inputs[name] = result.value;
53
+ }
54
+ return { inputs };
55
+ }
56
+ /** Runs a Test or Group node's tests with the inputs it resolves; an input that can't be resolved fails the node */
57
+ export function runWiredTests(node, run, list) {
58
+ const resolved = resolveNodeInputs(node, run);
59
+ if ("error" in resolved)
60
+ return Promise.resolve({ status: "failed", error: resolved.error });
61
+ return runNodeTests(node, run, list, resolved.inputs);
62
+ }
@@ -3,7 +3,7 @@ import type { WorkerPool } from "../../runner/worker-pool.js";
3
3
  import type { WorkItem } from "../../runner/worker-protocol.js";
4
4
  import type { SupportedBrowser } from "../../types/browser.js";
5
5
  import type { NodeTest } from "./events.js";
6
- /** The work items of a node: each test in each browser, the browsers one after the other */
7
- export declare function itemsOf(tests: NodeTest[], browsers: readonly SupportedBrowser[], outputDir?: string): WorkItem[];
6
+ /** The work items of a node in one browser's run: each of its tests, in that browser, with the node's inputs */
7
+ export declare function itemsOf(tests: NodeTest[], browser: SupportedBrowser, outputDir?: string, inputs?: Record<string, string>): WorkItem[];
8
8
  /** Runs the tests of a node on the pool and reports them as a run would */
9
9
  export declare function runOnPool(pool: Pick<WorkerPool, "run">, items: WorkItem[], onEvent: (event: RunEvent) => void, signal: AbortSignal): Promise<"passed" | "failed" | "cancelled">;
@@ -1,13 +1,13 @@
1
- /** The work items of a node: each test in each browser, the browsers one after the other */
2
- export function itemsOf(tests, browsers, outputDir) {
3
- return browsers
4
- .flatMap((browser) => tests.map((t) => ({
1
+ /** The work items of a node in one browser's run: each of its tests, in that browser, with the node's inputs */
2
+ export function itemsOf(tests, browser, outputDir, inputs) {
3
+ return tests.map((t, index) => ({
4
+ index,
5
5
  file: t.file,
6
6
  testName: t.name,
7
7
  browser,
8
8
  ...(outputDir !== undefined && { outputDir }),
9
- })))
10
- .map((item, index) => ({ ...item, index }));
9
+ ...(inputs && { inputs }),
10
+ }));
11
11
  }
12
12
  /** Runs the tests of a node on the pool and reports them as a run would */
13
13
  export async function runOnPool(pool, items, onEvent, signal) {
@@ -0,0 +1,24 @@
1
+ import type { runTests } from "../../runner/run.js";
2
+ import type { WorkerPool } from "../../runner/worker-pool.js";
3
+ import type { SupportedBrowser } from "../../types/browser.js";
4
+ import type { SamuraiTestConfig } from "../../types/config.js";
5
+ import type { RunFlowInProjectOptions } from "./project-options.js";
6
+ import type { RunFlowOptions } from "./run.js";
7
+ /** What every node's tests share in a project's flow run */
8
+ export interface NodeRunnerInput {
9
+ projectDir: string;
10
+ config: SamuraiTestConfig;
11
+ options: RunFlowInProjectOptions;
12
+ /** The environment the expressions see, when the project defines it */
13
+ environment?: string;
14
+ retries: number;
15
+ run: typeof runTests;
16
+ /** The run's worker pool, when `workers` is above 1 */
17
+ pool?: Pick<WorkerPool, "run">;
18
+ }
19
+ /**
20
+ * How a Test or Group node runs its tests in one browser's run of a project's flow: on the pool, else through
21
+ * `runTests` in this process. With an `outputDir`, each node writes to its own `nodeFolder` in it, and its
22
+ * screenshot paths are made relative to `outputDir`.
23
+ */
24
+ export declare function nodeRunner(input: NodeRunnerInput, browser: SupportedBrowser): RunFlowOptions["runTests"];
@@ -0,0 +1,48 @@
1
+ import path from "node:path";
2
+ import { inNodeFolder, nodeFolder } from "./node-folder.js";
3
+ import { itemsOf, runOnPool } from "./node-pool.js";
4
+ /**
5
+ * How a Test or Group node runs its tests in one browser's run of a project's flow: on the pool, else through
6
+ * `runTests` in this process. With an `outputDir`, each node writes to its own `nodeFolder` in it, and its
7
+ * screenshot paths are made relative to `outputDir`.
8
+ */
9
+ export function nodeRunner(input, browser) {
10
+ const { projectDir, config, options, environment, retries, run, pool } = input;
11
+ return async ({ nodeId, tests, onEvent: onTestEvent, signal, inputs }) => {
12
+ // Each node gets a folder of its own per browser: the same test in two nodes would overwrite its screenshots
13
+ const folder = nodeFolder(nodeId, browser);
14
+ const outputDir = options.outputDir !== undefined
15
+ ? path.join(options.outputDir, ...folder.split("/"))
16
+ : undefined;
17
+ // Screenshot paths come relative to the node's folder: make them relative to outputDir
18
+ const onEvent = outputDir !== undefined
19
+ ? (e) => onTestEvent(inNodeFolder(e, folder))
20
+ : onTestEvent;
21
+ if (pool)
22
+ return runOnPool(pool, itemsOf(tests, browser, outputDir, inputs), onEvent, signal);
23
+ const summary = await run({
24
+ projectDir,
25
+ config,
26
+ ...(options.dataDir !== undefined && { dataDir: options.dataDir }),
27
+ ...(environment !== undefined && { environment }),
28
+ ...(options.headless !== undefined && { headless: options.headless }),
29
+ ...(options.timeout !== undefined && { timeout: options.timeout }),
30
+ ...(options.expectTimeout !== undefined && {
31
+ expectTimeout: options.expectTimeout,
32
+ }),
33
+ ...(options.port !== undefined && { port: options.port }),
34
+ ...(retries > 0 && { retries }),
35
+ browsers: [browser],
36
+ files: [...new Set(tests.map((t) => t.file))],
37
+ testNames: tests.map((t) => t.name),
38
+ ...(inputs && { inputs }),
39
+ reportPath: false,
40
+ ...(outputDir !== undefined && { outputDir }),
41
+ signal,
42
+ onEvent,
43
+ });
44
+ if (signal.aborted)
45
+ return "cancelled";
46
+ return summary.status === "success" ? "passed" : "failed";
47
+ };
48
+ }
@@ -0,0 +1,21 @@
1
+ import type { FlowFile } from "../core/index.js";
2
+ import type { SupportedBrowser } from "../../types/browser.js";
3
+ import type { FlowEvent, FlowRunResult, FlowSummary } from "./events.js";
4
+ /**
5
+ * The browsers a flow runs in, one run each: the union of its nodes' browsers (`nodeBrowsers`, a Test node's
6
+ * the project's and a Group node's its group's), in the order the flow's nodes first name them. `only` keeps
7
+ * those of its browsers, in the flow's order; one no node uses is ignored, and none left is an error. A flow
8
+ * without Test or Group nodes runs once, in the first of `only`, else the first of `projectBrowsers`.
9
+ */
10
+ export declare function flowBrowsers(nodeBrowsers: Record<string, readonly SupportedBrowser[]>, flow: Pick<FlowFile, "nodes">, projectBrowsers: readonly SupportedBrowser[], only?: readonly SupportedBrowser[]): SupportedBrowser[];
11
+ /**
12
+ * Walks the flow once per browser, one after the other, then sends `flow-done`. The runs are independent: a
13
+ * failed one (an `onFailure: "stop"` included) ends that browser's run only. An abort stops them all: the runs
14
+ * left are not started. The result is cancelled only when a run was cut short or a browser was not started.
15
+ */
16
+ export declare function runPerBrowser(input: {
17
+ browsers: readonly SupportedBrowser[];
18
+ walk: (browser: SupportedBrowser) => Promise<FlowSummary>;
19
+ signal?: AbortSignal;
20
+ onEvent?: (event: FlowEvent) => void;
21
+ }): Promise<FlowRunResult>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The browsers a flow runs in, one run each: the union of its nodes' browsers (`nodeBrowsers`, a Test node's
3
+ * the project's and a Group node's its group's), in the order the flow's nodes first name them. `only` keeps
4
+ * those of its browsers, in the flow's order; one no node uses is ignored, and none left is an error. A flow
5
+ * without Test or Group nodes runs once, in the first of `only`, else the first of `projectBrowsers`.
6
+ */
7
+ export function flowBrowsers(nodeBrowsers, flow, projectBrowsers, only) {
8
+ const union = [
9
+ ...new Set(flow.nodes.flatMap((n) => nodeBrowsers[n.id] ?? [])),
10
+ ];
11
+ if (union.length === 0)
12
+ return [only?.[0] ?? projectBrowsers[0]];
13
+ if (!only)
14
+ return union;
15
+ const kept = union.filter((b) => only.includes(b));
16
+ if (kept.length === 0)
17
+ throw new Error(`runFlowInProject(): this flow runs in none of ${only.join(", ")}`);
18
+ return kept;
19
+ }
20
+ /**
21
+ * Walks the flow once per browser, one after the other, then sends `flow-done`. The runs are independent: a
22
+ * failed one (an `onFailure: "stop"` included) ends that browser's run only. An abort stops them all: the runs
23
+ * left are not started. The result is cancelled only when a run was cut short or a browser was not started.
24
+ */
25
+ export async function runPerBrowser(input) {
26
+ const runs = [];
27
+ for (const browser of input.browsers) {
28
+ if (input.signal?.aborted)
29
+ break;
30
+ const summary = await input.walk(browser);
31
+ runs.push({ browser, summary });
32
+ if (summary.status === "cancelled")
33
+ break;
34
+ }
35
+ // An abort that came after every run finished cuts nothing short
36
+ const cutShort = runs.length < input.browsers.length ||
37
+ runs.some((r) => r.summary.status === "cancelled");
38
+ const status = cutShort
39
+ ? "cancelled"
40
+ : runs.some((r) => r.summary.status === "failed")
41
+ ? "failed"
42
+ : "passed";
43
+ const result = { status, runs };
44
+ input.onEvent?.({ type: "flow-done", ...result });
45
+ return result;
46
+ }
@@ -0,0 +1,13 @@
1
+ import { type FlowFacts } from "../core/index.js";
2
+ import type { ResolvedGroup } from "../../runner/groups.js";
3
+ /** What `flowFacts` needs of a project's test, as the runner registers it */
4
+ interface TestIO {
5
+ id: string;
6
+ /** The inputs the test declares, with their defaults */
7
+ inputs?: Record<string, string>;
8
+ /** The outputs the test declares */
9
+ outputs?: string[];
10
+ }
11
+ /** The facts a flow is checked and wired with: each test's declared inputs and outputs, and each group's tests */
12
+ export declare function flowFacts(tests: readonly TestIO[], groups: readonly Pick<ResolvedGroup, "name" | "testIds">[]): FlowFacts;
13
+ export {};
@@ -0,0 +1,12 @@
1
+ import { flowFactsFrom } from "../core/index.js";
2
+ /** The facts a flow is checked and wired with: each test's declared inputs and outputs, and each group's tests */
3
+ export function flowFacts(tests, groups) {
4
+ return flowFactsFrom(tests.map((t) => ({
5
+ id: t.id,
6
+ inputs: Object.entries(t.inputs ?? {}).map(([name, value]) => ({
7
+ name,
8
+ default: value,
9
+ })),
10
+ ...(t.outputs && { outputs: t.outputs }),
11
+ })), groups);
12
+ }
@@ -3,6 +3,7 @@ import type { PoolOptions, WorkerPool } from "../../runner/worker-pool.js";
3
3
  import type { runTests } from "../../runner/run.js";
4
4
  import type { FlowFile, FlowProblem } from "../core/index.js";
5
5
  import type { FlowEvent } from "./events.js";
6
+ import type { SupportedBrowser } from "../../types/browser.js";
6
7
  /** The flow has errors; nothing was run */
7
8
  export declare class FlowCheckError extends Error {
8
9
  readonly problems: FlowProblem[];
@@ -20,10 +21,16 @@ export interface RunFlowInProjectOptions extends ProjectOptions {
20
21
  /** `"fail"` (default): a flow with a node that can't run is refused. `"skip"`: those nodes are skipped. */
21
22
  unsupported?: "fail" | "skip";
22
23
  port?: number;
24
+ /**
25
+ * Run the flow in these browsers only, in the flow's order (a browser no node uses is ignored, none left is an
26
+ * error). Unset runs it once in each browser its nodes use (see `flowBrowsers`); `[one]` runs it once
27
+ */
28
+ browsers?: SupportedBrowser[];
23
29
  /**
24
30
  * The folder for the tests' named screenshots, instead of `<project>/result` (see `runTests`). Each node writes to
25
- * `nodes/<node id>/` inside it, so a test that runs in two nodes keeps both sets; screenshot paths in a node's
26
- * results are relative to that folder. Relative to the project folder
31
+ * `<browser>/nodes/<node id>/` inside it (`nodeFolder`), so a test that runs in two nodes or two browsers keeps
32
+ * every set; screenshot paths in a node's test events start with that folder: they are relative to `outputDir`,
33
+ * with `/` separators. Relative to the project folder
27
34
  */
28
35
  outputDir?: string;
29
36
  /**
@@ -1,7 +1,7 @@
1
1
  import { type FlowFile, type FlowProblem } from "../core/index.js";
2
2
  import { type ResolvedGroup } from "../../runner/groups.js";
3
3
  import type { SamuraiTestConfig } from "../../types/config.js";
4
- import type { FlowSummary, NodeTest } from "./events.js";
4
+ import type { FlowRunResult, NodeTest } from "./events.js";
5
5
  import { FlowCheckError, type FlowRunOptions, type RunFlowInProjectOptions } from "./project-options.js";
6
6
  import type { SupportedBrowser } from "../../types/browser.js";
7
7
  export { FlowCheckError };
@@ -15,15 +15,19 @@ interface ProjectTest {
15
15
  name: string;
16
16
  /** The spec file relative to `srcDir`, with `/` separators */
17
17
  relative: string;
18
+ /** The inputs the test declares, with their defaults */
19
+ inputs?: Record<string, string>;
20
+ /** The outputs the test declares */
21
+ outputs?: string[];
18
22
  }
19
- /** The tests, and the browsers, of each Test and Group node of a flow; a Test node uses the project's browsers */
20
- export declare function nodeTestsOf(flow: FlowFile, tests: Pick<ProjectTest, "id" | "file" | "name">[], groups: Pick<ResolvedGroup, "name" | "testIds" | "browsers">[]): {
23
+ /** The tests, and the browsers, of each Test and Group node of a flow: a Test node's are the project's, a Group node's its group's */
24
+ export declare function nodeTestsOf(flow: FlowFile, tests: Pick<ProjectTest, "id" | "file" | "name">[], groups: Pick<ResolvedGroup, "name" | "testIds" | "browsers">[], projectBrowsers: SupportedBrowser[]): {
21
25
  nodeTests: Record<string, NodeTest[]>;
22
26
  nodeBrowsers: Record<string, SupportedBrowser[]>;
23
27
  };
24
28
  /**
25
29
  * Checks a flow against the project without running it: its tests, groups and environment
26
- * variables, and which kinds can run. Returns the flow (with keys) and every problem, warnings included.
30
+ * variables, which kinds can run, and where each Test and Group node's inputs come from. Returns the flow (with keys) and every problem, warnings included.
27
31
  */
28
32
  export declare function checkFlowInProject(options: Pick<RunFlowInProjectOptions, "flow" | "flowId" | "projectDir" | "config" | "dataDir" | "environment" | "unsupported">): Promise<{
29
33
  flow: FlowFile;
@@ -31,8 +35,9 @@ export declare function checkFlowInProject(options: Pick<RunFlowInProjectOptions
31
35
  }>;
32
36
  /**
33
37
  * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
34
- * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
35
- * its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
36
- * One at a time per process.
38
+ * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow once per browser (see `flowBrowsers`),
39
+ * one browser after the other, each run with its own variables and results. Each Test and Group node runs its
40
+ * tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
41
+ * Resolves to every run's summary; the last event is `flow-done`. One at a time per process.
37
42
  */
38
- export declare function runFlowInProject(options: RunFlowInProjectOptions): Promise<FlowSummary>;
43
+ export declare function runFlowInProject(options: RunFlowInProjectOptions): Promise<FlowRunResult>;