@itmetsam/samurai-framework 0.20.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.
Files changed (132) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/assert/assertion-error.d.ts +3 -1
  3. package/dist/assert/assertion-error.js +2 -2
  4. package/dist/assert/locator-assertions.d.ts +35 -4
  5. package/dist/assert/locator-assertions.js +56 -28
  6. package/dist/assert/page-assertions.js +1 -1
  7. package/dist/assert/pending-assertions.d.ts +3 -2
  8. package/dist/assert/pending-assertions.js +5 -4
  9. package/dist/assert/text-match.d.ts +6 -0
  10. package/dist/assert/text-match.js +12 -0
  11. package/dist/capabilities.d.ts +16 -5
  12. package/dist/capabilities.js +16 -5
  13. package/dist/cli/flow-reporter.d.ts +1 -1
  14. package/dist/cli/flow-reporter.js +2 -2
  15. package/dist/cli/flow.js +4 -4
  16. package/dist/flows/core/check-io.d.ts +23 -0
  17. package/dist/flows/core/check-io.js +84 -0
  18. package/dist/flows/core/check.d.ts +16 -2
  19. package/dist/flows/core/check.js +20 -4
  20. package/dist/flows/core/expression/scope.d.ts +9 -2
  21. package/dist/flows/core/expression/scope.js +12 -4
  22. package/dist/flows/core/index.d.ts +2 -1
  23. package/dist/flows/core/index.js +2 -1
  24. package/dist/flows/core/nodes/condition.js +0 -1
  25. package/dist/flows/core/nodes/define.d.ts +0 -2
  26. package/dist/flows/core/nodes/group.js +0 -1
  27. package/dist/flows/core/nodes/test.js +0 -1
  28. package/dist/flows/core/schema.d.ts +10 -5
  29. package/dist/flows/core/schema.js +29 -1
  30. package/dist/flows/core/steps.d.ts +7 -0
  31. package/dist/flows/core/steps.js +15 -0
  32. package/dist/flows/core/wiring.d.ts +72 -0
  33. package/dist/flows/core/wiring.js +151 -0
  34. package/dist/flows/run/branch-context.d.ts +10 -4
  35. package/dist/flows/run/branch-context.js +6 -4
  36. package/dist/flows/run/events.d.ts +30 -2
  37. package/dist/flows/run/handlers/group.js +6 -2
  38. package/dist/flows/run/handlers/support.d.ts +7 -2
  39. package/dist/flows/run/handlers/support.js +36 -7
  40. package/dist/flows/run/handlers/test.js +6 -2
  41. package/dist/flows/run/handlers/types.d.ts +11 -3
  42. package/dist/flows/run/node-folder.d.ts +8 -3
  43. package/dist/flows/run/node-folder.js +28 -4
  44. package/dist/flows/run/node-inputs.d.ts +5 -0
  45. package/dist/flows/run/node-inputs.js +62 -0
  46. package/dist/flows/run/node-pool.d.ts +2 -2
  47. package/dist/flows/run/node-pool.js +6 -6
  48. package/dist/flows/run/node-runner.d.ts +24 -0
  49. package/dist/flows/run/node-runner.js +48 -0
  50. package/dist/flows/run/per-browser.d.ts +21 -0
  51. package/dist/flows/run/per-browser.js +46 -0
  52. package/dist/flows/run/project-facts.d.ts +13 -0
  53. package/dist/flows/run/project-facts.js +12 -0
  54. package/dist/flows/run/project-options.d.ts +9 -2
  55. package/dist/flows/run/project.d.ts +13 -8
  56. package/dist/flows/run/project.js +48 -51
  57. package/dist/flows/run/report.d.ts +7 -7
  58. package/dist/flows/run/report.js +3 -5
  59. package/dist/flows/run/run.d.ts +21 -7
  60. package/dist/flows/run/run.js +21 -11
  61. package/dist/flows/run/walker.d.ts +23 -7
  62. package/dist/flows/run/walker.js +18 -7
  63. package/dist/locator/locator.d.ts +3 -1
  64. package/dist/locator/locator.js +6 -0
  65. package/dist/recorder/apply.js +17 -7
  66. package/dist/recorder/normalise.d.ts +18 -3
  67. package/dist/recorder/normalise.js +18 -3
  68. package/dist/recorder/session-secrets.js +4 -0
  69. package/dist/recorder/toolbar/action-guard.d.ts +2 -5
  70. package/dist/recorder/toolbar/action-guard.js +9 -5
  71. package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
  72. package/dist/recorder/toolbar/actions/insert.js +43 -7
  73. package/dist/recorder/toolbar/actions/pick.js +2 -0
  74. package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
  75. package/dist/recorder/toolbar/actions/reply.js +9 -0
  76. package/dist/recorder/toolbar/actions/save-output.d.ts +9 -0
  77. package/dist/recorder/toolbar/actions/save-output.js +46 -0
  78. package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
  79. package/dist/recorder/toolbar/controller-fixture.js +2 -0
  80. package/dist/recorder/toolbar/controller.d.ts +2 -1
  81. package/dist/recorder/toolbar/controller.js +14 -7
  82. package/dist/recorder/toolbar/insertables.d.ts +7 -0
  83. package/dist/recorder/toolbar/insertables.js +12 -0
  84. package/dist/recorder/toolbar/inspect.js +3 -1
  85. package/dist/recorder/toolbar/protocol.d.ts +20 -3
  86. package/dist/recorder/toolbar/source/events-page.js +2 -1
  87. package/dist/recorder/toolbar/source/fields.js +8 -0
  88. package/dist/recorder/toolbar/source/handlers.js +19 -12
  89. package/dist/recorder/toolbar/source/picking.js +22 -7
  90. package/dist/recorder/toolbar/source/recorder-side.js +6 -0
  91. package/dist/recorder/toolbar/source/setup.js +3 -3
  92. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  93. package/dist/recorder/toolbar/source/views-panels.js +50 -10
  94. package/dist/recorder/toolbar/source/views-rail.js +5 -2
  95. package/dist/recorder/toolbar/source.d.ts +5 -5
  96. package/dist/recorder/toolbar/source.js +5 -5
  97. package/dist/recorder/toolbar/types.d.ts +10 -3
  98. package/dist/runner/options.d.ts +5 -0
  99. package/dist/runner/pooled-run.d.ts +3 -2
  100. package/dist/runner/pooled-run.js +4 -2
  101. package/dist/runner/registry.d.ts +7 -3
  102. package/dist/runner/registry.js +29 -11
  103. package/dist/runner/reporter.d.ts +4 -0
  104. package/dist/runner/reporter.js +2 -0
  105. package/dist/runner/run-test.js +23 -2
  106. package/dist/runner/run.d.ts +7 -0
  107. package/dist/runner/run.js +8 -1
  108. package/dist/runner/test-io.d.ts +30 -0
  109. package/dist/runner/test-io.js +111 -0
  110. package/dist/runner/test-runner.js +1 -1
  111. package/dist/runner/worker-protocol.d.ts +2 -0
  112. package/dist/runner/worker-session.js +1 -0
  113. package/dist/runner/worker.d.ts +1 -0
  114. package/dist/runner/worker.js +4 -1
  115. package/dist/steps/edit.d.ts +14 -3
  116. package/dist/steps/edit.js +65 -23
  117. package/dist/steps/emit.d.ts +2 -0
  118. package/dist/steps/emit.js +32 -8
  119. package/dist/steps/index.d.ts +1 -1
  120. package/dist/steps/model.d.ts +42 -2
  121. package/dist/steps/parse/expectation.d.ts +8 -1
  122. package/dist/steps/parse/expectation.js +17 -11
  123. package/dist/steps/parse/step.js +64 -8
  124. package/dist/steps/parse.js +25 -16
  125. package/dist/steps/test-options.d.ts +25 -0
  126. package/dist/steps/test-options.js +158 -0
  127. package/dist/steps/text.d.ts +7 -0
  128. package/dist/steps/text.js +12 -0
  129. package/dist/types/test.d.ts +34 -0
  130. package/dist/wait/output-sink.d.ts +24 -0
  131. package/dist/wait/output-sink.js +59 -0
  132. package/package.json +1 -1
@@ -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>;
@@ -14,8 +14,9 @@ import TestRunner from "../../runner/test-runner.js";
14
14
  import { WorkerPool } from "../../runner/worker-pool.js";
15
15
  import { processWorkerFactory } from "../../runner/worker-process.js";
16
16
  import { readFlow } from "./files.js";
17
- import { nodeFolder } from "./node-folder.js";
18
- import { itemsOf, runOnPool } from "./node-pool.js";
17
+ import { flowFacts } from "./project-facts.js";
18
+ import { nodeRunner } from "./node-runner.js";
19
+ import { flowBrowsers, runPerBrowser } from "./per-browser.js";
19
20
  import { FlowCheckError, } from "./project-options.js";
20
21
  import { projectSteps } from "../steps/set.js";
21
22
  import { HANDLERS } from "./handlers/index.js";
@@ -34,8 +35,8 @@ export function chooseEnvironment(config, flow, option) {
34
35
  return flow.envDefault;
35
36
  return config.defaultEnvironment ?? names[0] ?? "default";
36
37
  }
37
- /** The tests, and the browsers, of each Test and Group node of a flow; a Test node uses the project's browsers */
38
- export function nodeTestsOf(flow, tests, groups) {
38
+ /** 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 */
39
+ export function nodeTestsOf(flow, tests, groups, projectBrowsers) {
39
40
  const byId = new Map(tests.map((t) => [t.id, t]));
40
41
  const nodeTests = {};
41
42
  const nodeBrowsers = {};
@@ -52,6 +53,8 @@ export function nodeTestsOf(flow, tests, groups) {
52
53
  continue;
53
54
  if (group)
54
55
  nodeBrowsers[node.id] = group.browsers;
56
+ if (node.kind === "test")
57
+ nodeBrowsers[node.id] = projectBrowsers;
55
58
  nodeTests[node.id] = ids.flatMap((id) => {
56
59
  const t = byId.get(id);
57
60
  return t ? [{ file: t.file, name: t.name }] : [];
@@ -92,10 +95,13 @@ async function prepare(options) {
92
95
  file: t.file,
93
96
  name: t.name,
94
97
  relative: toPosix(path.relative(srcDir, t.file)),
98
+ ...(t.inputs && { inputs: t.inputs }),
99
+ ...(t.outputs && { outputs: t.outputs }),
95
100
  }));
96
101
  const groups = resolveGroups(config, tests.map((t) => ({ file: t.relative, name: t.name })), realDir, srcDir);
97
102
  return { tests, groups };
98
103
  });
104
+ const facts = flowFacts(tests, groups);
99
105
  const problems = checkFlow(flow, {
100
106
  tests: tests.map((t) => t.id),
101
107
  groups: groups.map((g) => g.name),
@@ -106,6 +112,7 @@ async function prepare(options) {
106
112
  ]),
107
113
  unsupported,
108
114
  nodes: steps.nodes,
115
+ facts,
109
116
  });
110
117
  return {
111
118
  projectDir,
@@ -117,23 +124,27 @@ async function prepare(options) {
117
124
  environment,
118
125
  tests,
119
126
  groups,
127
+ facts,
120
128
  problems,
121
129
  };
122
130
  }
123
131
  /**
124
132
  * Checks a flow against the project without running it: its tests, groups and environment
125
- * variables, and which kinds can run. Returns the flow (with keys) and every problem, warnings included.
133
+ * 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.
126
134
  */
127
135
  export async function checkFlowInProject(options) {
128
136
  const { flow, problems } = await prepare(options);
129
137
  return { flow, problems };
130
138
  }
131
139
  async function walkFlowInProject(options) {
132
- const { projectDir, flow, unsupported, config, steps, name, environment, tests, groups, problems, } = await prepare(options);
140
+ const { projectDir, flow, unsupported, config, steps, name, environment, tests, groups, facts, problems, } = await prepare(options);
133
141
  const errors = problems.filter((p) => p.level === "error");
134
142
  if (errors.length)
135
143
  throw new FlowCheckError(errors);
136
- const { nodeTests, nodeBrowsers } = nodeTestsOf(flow, tests, groups);
144
+ const projectBrowsers = browsersFor({ project: config.browsers });
145
+ const { nodeTests, nodeBrowsers } = nodeTestsOf(flow, tests, groups, projectBrowsers);
146
+ // Before anything starts: a list of browsers this flow doesn't use is refused
147
+ const browsers = flowBrowsers(nodeBrowsers, flow, projectBrowsers, options.browsers);
137
148
  const run = options.testRunner ?? runTests;
138
149
  // An injected runner (tests) stays in this process unless the workers are asked for
139
150
  const asked = options.workers !== undefined ||
@@ -183,50 +194,35 @@ async function walkFlowInProject(options) {
183
194
  },
184
195
  })
185
196
  : undefined;
197
+ const walk = (browser) => runFlow({
198
+ flow,
199
+ browser,
200
+ nodeTests,
201
+ nodeBrowsers,
202
+ environment,
203
+ facts,
204
+ ...(options.signal && { signal: options.signal }),
205
+ ...(options.onEvent && { onEvent: options.onEvent }),
206
+ ...(options.sleep && { sleep: options.sleep }),
207
+ unsupported,
208
+ handlers: { ...HANDLERS, ...steps.handlers },
209
+ nodes: steps.nodes,
210
+ runTests: nodeRunner({
211
+ projectDir,
212
+ config,
213
+ options,
214
+ ...(hasEnvironment(config, name) && { environment: name }),
215
+ retries,
216
+ run,
217
+ ...(pool && { pool }),
218
+ }, browser),
219
+ });
186
220
  try {
187
- return await runFlow({
188
- flow,
189
- nodeTests,
190
- nodeBrowsers,
191
- environment,
221
+ return await runPerBrowser({
222
+ browsers,
223
+ walk,
192
224
  ...(options.signal && { signal: options.signal }),
193
225
  ...(options.onEvent && { onEvent: options.onEvent }),
194
- ...(options.sleep && { sleep: options.sleep }),
195
- unsupported,
196
- handlers: { ...HANDLERS, ...steps.handlers },
197
- nodes: steps.nodes,
198
- runTests: async ({ nodeId, tests, onEvent, signal, browsers }) => {
199
- // Each node gets a folder of its own: the same test in two nodes would overwrite its screenshots
200
- const outputDir = options.outputDir !== undefined
201
- ? path.join(options.outputDir, ...nodeFolder(nodeId).split("/"))
202
- : undefined;
203
- if (pool)
204
- return runOnPool(pool, itemsOf(tests, browsers ?? browsersFor({ project: config.browsers }), outputDir), onEvent, signal);
205
- const summary = await run({
206
- projectDir,
207
- config,
208
- ...(options.dataDir !== undefined && { dataDir: options.dataDir }),
209
- // The environment the expressions see, whenever the project defines it
210
- ...(hasEnvironment(config, name) ? { environment: name } : {}),
211
- ...(options.headless !== undefined && { headless: options.headless }),
212
- ...(options.timeout !== undefined && { timeout: options.timeout }),
213
- ...(options.expectTimeout !== undefined && {
214
- expectTimeout: options.expectTimeout,
215
- }),
216
- ...(options.port !== undefined && { port: options.port }),
217
- ...(retries > 0 && { retries }),
218
- ...(browsers && { browsers }),
219
- files: [...new Set(tests.map((t) => t.file))],
220
- testNames: tests.map((t) => t.name),
221
- reportPath: false,
222
- ...(outputDir !== undefined && { outputDir }),
223
- signal,
224
- onEvent,
225
- });
226
- if (signal.aborted)
227
- return "cancelled";
228
- return summary.status === "success" ? "passed" : "failed";
229
- },
230
226
  });
231
227
  }
232
228
  finally {
@@ -235,9 +231,10 @@ async function walkFlowInProject(options) {
235
231
  }
236
232
  /**
237
233
  * Checks and runs a flow of a project: loads its config, lists its tests and groups, refuses to start
238
- * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow; each Test and Group node runs
239
- * its tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
240
- * One at a time per process.
234
+ * when `checkFlow` finds errors (`FlowCheckError`), then walks the flow once per browser (see `flowBrowsers`),
235
+ * one browser after the other, each run with its own variables and results. Each Test and Group node runs its
236
+ * tests through `runTests`, one after the other, or on one shared worker pool when `workers` is above 1.
237
+ * Resolves to every run's summary; the last event is `flow-done`. One at a time per process.
241
238
  */
242
239
  export async function runFlowInProject(options) {
243
240
  // One log for the whole run, across the project sessions it opens one after the other
@@ -1,5 +1,5 @@
1
1
  import type { TestResult } from "../../types/test.js";
2
- import type { FlowEvent, FlowSummary, NodeResult } from "./events.js";
2
+ import type { FlowBrowserRun, FlowEvent, FlowRunResult } from "./events.js";
3
3
  /** One test a Test or Group node ran, as the runner reported it */
4
4
  export type FlowTestResult = {
5
5
  nodeId: string;
@@ -9,14 +9,14 @@ export interface FlowReport {
9
9
  flow: string;
10
10
  name: string;
11
11
  environment: string;
12
- status: FlowSummary["status"];
12
+ /** Across the browsers: see `FlowRunResult` */
13
+ status: FlowRunResult["status"];
13
14
  startedAt: string;
14
15
  durationMs: number;
15
- nodes: Record<string, NodeResult>;
16
- vars: Record<string, unknown>;
16
+ /** Every test of every run; each names its browser */
17
17
  tests: FlowTestResult[];
18
- /** The flow couldn't be walked at all */
19
- error?: string;
18
+ /** One entry per browser the flow ran in, in order: its nodes, variables and error */
19
+ runs: FlowBrowserRun[];
20
20
  }
21
21
  /** Follows a run's events and builds its report once it ends (or `finish` is given what is known) */
22
22
  export declare class FlowReportBuilder {
@@ -31,7 +31,7 @@ export declare class FlowReportBuilder {
31
31
  });
32
32
  handle(event: FlowEvent): void;
33
33
  private collect;
34
- finish(summary: FlowSummary, environment?: string): FlowReport;
34
+ finish(result: FlowRunResult, environment?: string): FlowReport;
35
35
  }
36
36
  /** Writes `<projectDir>/result/flows/<id>.json` with the registered secrets masked, as `report.json` is. Returns the path */
37
37
  export declare function writeFlowReport(projectDir: string, report: FlowReport): Promise<string>;