@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
@@ -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>;
@@ -20,18 +20,16 @@ export class FlowReportBuilder {
20
20
  if (event.type === "test-end")
21
21
  this.tests.push({ nodeId, ...event.result });
22
22
  }
23
- finish(summary, environment = this.environment) {
23
+ finish(result, environment = this.environment) {
24
24
  return {
25
25
  flow: this.flow.id,
26
26
  name: this.flow.name,
27
27
  environment,
28
- status: summary.status,
28
+ status: result.status,
29
29
  startedAt: this.startedAt,
30
30
  durationMs: Date.now() - this.started,
31
- nodes: summary.nodes,
32
- vars: summary.vars,
33
31
  tests: this.tests,
34
- ...(summary.error ? { error: summary.error } : {}),
32
+ runs: result.runs,
35
33
  };
36
34
  }
37
35
  }
@@ -1,4 +1,4 @@
1
- import { type AnyNodeDefinition, type FlowFile } from "../core/index.js";
1
+ import { type AnyNodeDefinition, type FlowFacts, type FlowFile } from "../core/index.js";
2
2
  import type { RunEvent } from "../../runner/reporter.js";
3
3
  import type { FlowEvent, FlowSummary, NodeTest } from "./events.js";
4
4
  import { type Handler } from "./handlers/index.js";
@@ -7,12 +7,23 @@ export interface RunFlowOptions {
7
7
  flow: FlowFile;
8
8
  /** The tests of each Test and Group node, by node id */
9
9
  nodeTests: Record<string, NodeTest[]>;
10
- /** The browsers a Group node runs in (its group's); a node not listed uses the project's */
10
+ /** The browser of this walk: a flow runs once per browser, `runFlow` walks it once */
11
+ browser: SupportedBrowser;
12
+ /**
13
+ * The browsers each Test and Group node runs in. A listed node whose list lacks `browser` is skipped with the
14
+ * note `not run in <browser>`; a node not listed runs
15
+ */
11
16
  nodeBrowsers?: Record<string, SupportedBrowser[]>;
12
17
  environment: {
13
18
  name: string;
14
19
  variables: Record<string, string | number | boolean>;
15
20
  };
21
+ /**
22
+ * The project's tests' inputs and outputs and its groups' tests: with them, each Test and Group node's inputs are
23
+ * wired (`wiringAt`) and sent to its tests, and expressions can read a Test node's outputs. Without them no input
24
+ * is sent
25
+ */
26
+ facts?: FlowFacts;
16
27
  /** @default "manual" */
17
28
  trigger?: "manual";
18
29
  /** @default now */
@@ -25,8 +36,10 @@ export interface RunFlowOptions {
25
36
  tests: NodeTest[];
26
37
  onEvent: (e: RunEvent) => void;
27
38
  signal: AbortSignal;
28
- /** Set for a node that runs in browsers of its own */
29
- browsers?: SupportedBrowser[];
39
+ /** The run's browser */
40
+ browser: SupportedBrowser;
41
+ /** Values for the tests' inputs; absent when the node sends none */
42
+ inputs?: Record<string, string>;
30
43
  }) => Promise<"passed" | "failed" | "cancelled">;
31
44
  sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
32
45
  /**
@@ -41,8 +54,9 @@ export interface RunFlowOptions {
41
54
  nodes?: readonly AnyNodeDefinition[];
42
55
  }
43
56
  /**
44
- * Runs a flow: sets up what its nodes share (expression context, handler access to the tests), walks it from
45
- * Start (see `FlowWalker`) and reports how it ended. A flow whose shape can't run ends with an `error`; a flow
46
- * with a kind that has no handler throws before it starts, unless `unsupported` is `"skip"`.
57
+ * Runs a flow once, in `browser` (`runFlowInProject` runs it once per browser): sets up what its nodes share
58
+ * (expression context, handler access to the tests), walks it from Start (see `FlowWalker`) and reports how it
59
+ * ended. Every event carries `browser`. A flow whose shape can't run ends with an `error`; a flow with a kind that
60
+ * has no handler throws before it starts, unless `unsupported` is `"skip"`.
47
61
  */
48
62
  export declare function runFlow(options: RunFlowOptions): Promise<FlowSummary>;
@@ -5,12 +5,13 @@ import { HANDLERS } from "./handlers/index.js";
5
5
  import { assertRunnable, defaultSleep, definitionOf, ShapeError, shapeError, } from "./prepare-flow.js";
6
6
  import { FlowWalker } from "./walker.js";
7
7
  /**
8
- * Runs a flow: sets up what its nodes share (expression context, handler access to the tests), walks it from
9
- * Start (see `FlowWalker`) and reports how it ended. A flow whose shape can't run ends with an `error`; a flow
10
- * with a kind that has no handler throws before it starts, unless `unsupported` is `"skip"`.
8
+ * Runs a flow once, in `browser` (`runFlowInProject` runs it once per browser): sets up what its nodes share
9
+ * (expression context, handler access to the tests), walks it from Start (see `FlowWalker`) and reports how it
10
+ * ended. Every event carries `browser`. A flow whose shape can't run ends with an `error`; a flow with a kind that
11
+ * has no handler throws before it starts, unless `unsupported` is `"skip"`.
11
12
  */
12
13
  export async function runFlow(options) {
13
- const { flow, onEvent = () => { } } = options;
14
+ const { flow, browser, onEvent = () => { } } = options;
14
15
  const signal = options.signal ?? new AbortController().signal;
15
16
  const handlers = options.handlers ?? HANDLERS;
16
17
  const nodes = options.nodes ?? STEP_NODES;
@@ -26,22 +27,26 @@ export async function runFlow(options) {
26
27
  startedAt: options.startedAt ?? new Date().toISOString(),
27
28
  },
28
29
  };
29
- const handlerRunFor = (context, signal) => ({
30
+ const handlerRunFor = (context, outputsById, signal) => ({
30
31
  flow,
31
32
  environment: options.environment,
32
33
  context,
34
+ outputsById,
35
+ ...(options.facts && { facts: options.facts }),
33
36
  nodeTests: options.nodeTests,
37
+ browser,
34
38
  ...(options.nodeBrowsers && { nodeBrowsers: options.nodeBrowsers }),
35
39
  signal,
36
40
  sleep: options.sleep ?? defaultSleep,
37
- runTests: (nodeId, tests, onTestEvent, browsers) => options.runTests({
41
+ runTests: (nodeId, tests, onTestEvent, inputs) => options.runTests({
38
42
  nodeId,
39
43
  tests,
40
44
  onEvent: onTestEvent,
41
45
  signal,
42
- ...(browsers && { browsers }),
46
+ browser,
47
+ ...(inputs && { inputs }),
43
48
  }),
44
- onTestEvent: (nodeId, event) => onEvent({ type: "test", nodeId, event }),
49
+ onTestEvent: (nodeId, event) => onEvent({ type: "test", browser, nodeId, event }),
45
50
  nodes,
46
51
  log: (nodeId, level, text) => logger[level](`[${nodeId}] ${text}`),
47
52
  });
@@ -52,9 +57,14 @@ export async function runFlow(options) {
52
57
  nodes,
53
58
  handlerRunFor,
54
59
  context,
55
- onEvent,
60
+ outputsById: {},
61
+ onEvent: (event) => onEvent({ ...event, browser }),
62
+ });
63
+ onEvent({
64
+ type: "flow-start",
65
+ browser,
66
+ environment: options.environment.name,
56
67
  });
57
- onEvent({ type: "flow-start", environment: options.environment.name });
58
68
  let error;
59
69
  const start = flow.nodes.find((n) => definitionOf(nodes, n.kind)?.shape === "start");
60
70
  try {
@@ -82,6 +92,6 @@ export async function runFlow(options) {
82
92
  vars: context.vars,
83
93
  ...(error ? { error } : {}),
84
94
  };
85
- onEvent({ type: "flow-end", summary });
95
+ onEvent({ type: "flow-end", browser, summary });
86
96
  return summary;
87
97
  }
@@ -1,17 +1,30 @@
1
1
  import { type AnyNodeDefinition, type ExpressionContext, type FlowFile } from "../core/index.js";
2
- import { type Fork } from "./branch-context.js";
3
- import type { FlowEvent, NodeResult } from "./events.js";
2
+ import { type Fork, type OutputsById } from "./branch-context.js";
3
+ import type { NodeResult } from "./events.js";
4
4
  import type { Handler, HandlerRun } from "./handlers/index.js";
5
+ /** A node's event as the walker sends it; `runFlow` adds the run's browser */
6
+ type NodeEvent = {
7
+ type: "node-start";
8
+ nodeId: string;
9
+ } | {
10
+ type: "node-end";
11
+ nodeId: string;
12
+ result: NodeResult;
13
+ };
5
14
  /** What the walker needs of the run: the flow, who handles each kind, and where results and events go */
6
15
  export interface WalkerInput {
7
16
  flow: FlowFile;
8
17
  signal: AbortSignal;
9
18
  handlers: Readonly<Record<string, Handler>>;
10
19
  nodes: readonly AnyNodeDefinition[];
11
- /** What the handlers get for a context and a signal; the walker also writes each result into `context.nodes` */
12
- handlerRunFor: (context: ExpressionContext, signal: AbortSignal) => HandlerRun;
20
+ /**
21
+ * What the handlers get for a context, the outputs by node id and a signal; the walker also writes each result
22
+ * into `context.nodes` and its outputs into `outputsById`
23
+ */
24
+ handlerRunFor: (context: ExpressionContext, outputsById: OutputsById, signal: AbortSignal) => HandlerRun;
13
25
  context: ExpressionContext;
14
- onEvent: (event: FlowEvent) => void;
26
+ outputsById: OutputsById;
27
+ onEvent: (event: NodeEvent) => void;
15
28
  }
16
29
  /**
17
30
  * Walks the flow from Start by the shape of each node's kind: a step runs and follows its one way out; a node
@@ -33,8 +46,10 @@ export declare class FlowWalker {
33
46
  results: Record<string, NodeResult>;
34
47
  visited: Set<string>;
35
48
  });
36
- /** The variables and node outputs this walk reads and writes */
49
+ /** The variables and node outputs (by key) this walk reads and writes */
37
50
  get context(): ExpressionContext;
51
+ /** The node outputs by node id this walk reads and writes */
52
+ get outputsById(): OutputsById;
38
53
  private definition;
39
54
  private record;
40
55
  private out;
@@ -49,8 +64,9 @@ export declare class FlowWalker {
49
64
  * the others.
50
65
  */
51
66
  private walkBranches;
52
- /** A walker for one branch: the same flow, results and visited nodes, but its own variables and its own signal */
67
+ /** A walker for one branch: the same flow, results and visited nodes, but its own variables, node outputs and signal */
53
68
  forBranch(fork: Fork, signal: AbortSignal): FlowWalker;
54
69
  /** Runs the chain from `from` up to `stop` (undefined: through End) */
55
70
  walk(from: string, stop?: string): Promise<void>;
56
71
  }
72
+ export {};
@@ -1,5 +1,5 @@
1
1
  import { inFillOrder, meetOf, } from "../core/index.js";
2
- import { forkContext, mergeForks } from "./branch-context.js";
2
+ import { forkContext, mergeForks, } from "./branch-context.js";
3
3
  import { definitionOf, dynamicBranches, shapeError } from "./prepare-flow.js";
4
4
  /**
5
5
  * Walks the flow from Start by the shape of each node's kind: a step runs and follows its one way out; a node
@@ -13,17 +13,23 @@ export class FlowWalker {
13
13
  this.byId = new Map(input.flow.nodes.map((n) => [n.id, n]));
14
14
  this.results = shared.results;
15
15
  this.visited = shared.visited;
16
- this.handlerRun = input.handlerRunFor(input.context, input.signal);
16
+ this.handlerRun = input.handlerRunFor(input.context, input.outputsById, input.signal);
17
17
  }
18
- /** The variables and node outputs this walk reads and writes */
18
+ /** The variables and node outputs (by key) this walk reads and writes */
19
19
  get context() {
20
20
  return this.input.context;
21
21
  }
22
+ /** The node outputs by node id this walk reads and writes */
23
+ get outputsById() {
24
+ return this.input.outputsById;
25
+ }
22
26
  definition(kind) {
23
27
  return definitionOf(this.input.nodes, kind);
24
28
  }
25
29
  record(node, result) {
26
30
  this.results[node.id] = result;
31
+ if (result.outputs)
32
+ this.input.outputsById[node.id] = result.outputs;
27
33
  if (node.key)
28
34
  this.input.context.nodes[node.key] = {
29
35
  ...result.outputs,
@@ -131,7 +137,7 @@ export class FlowWalker {
131
137
  delete this.results[node.id];
132
138
  return;
133
139
  }
134
- const { clash } = mergeForks(this.input.context, walks.map((w) => w.fork));
140
+ const { clash } = mergeForks(this.input.context, this.input.outputsById, walks.map((w) => w.fork));
135
141
  // The node fails when anything in its branches failed
136
142
  const failed = flow.nodes.some((n) => this.results[n.id]?.status === "failed" &&
137
143
  edges.some((e) => e.target !== meet && this.reachesBefore(e.target, n.id, meet)));
@@ -156,9 +162,14 @@ export class FlowWalker {
156
162
  if (ended.status === "failed" && node.onFailure === "stop")
157
163
  this.stopped = "failed";
158
164
  }
159
- /** A walker for one branch: the same flow, results and visited nodes, but its own variables and its own signal */
165
+ /** A walker for one branch: the same flow, results and visited nodes, but its own variables, node outputs and signal */
160
166
  forBranch(fork, signal) {
161
- return new FlowWalker({ ...this.input, context: fork.context, signal }, { results: this.results, visited: this.visited });
167
+ return new FlowWalker({
168
+ ...this.input,
169
+ context: fork.context,
170
+ outputsById: fork.outputsById,
171
+ signal,
172
+ }, { results: this.results, visited: this.visited });
162
173
  }
163
174
  /** Runs the chain from `from` up to `stop` (undefined: through End) */
164
175
  async walk(from, stop) {
@@ -208,7 +219,7 @@ export class FlowWalker {
208
219
  /** One branch of a Parallel node, walking from its first node up to where the branches meet */
209
220
  class BranchWalk {
210
221
  constructor(parent, from, meet, cancel) {
211
- this.fork = forkContext(parent.context);
222
+ this.fork = forkContext(parent.context, parent.outputsById);
212
223
  this.walker = parent.forBranch(this.fork, cancel.signal);
213
224
  this.done = this.walker.walk(from, meet).then(() => {
214
225
  // A failure that stops the flow cancels the branches still running
@@ -169,5 +169,7 @@ export default class Locator {
169
169
  */
170
170
  press(key: string, options?: ActionOptions): Promise<void>;
171
171
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
172
- fill(value: string, options?: ActionOptions): Promise<void>;
172
+ fill(value: string, options?: ActionOptions & {
173
+ output?: string;
174
+ }): Promise<void>;
173
175
  }
@@ -6,6 +6,7 @@ import { probeState, waitForActionable, waitForState, } from "./actionability.js
6
6
  import { ChainResolver } from "./chain-resolver.js";
7
7
  import { dropFiles, setInputFiles, } from "./file-actions.js";
8
8
  import { describeType, } from "./element-state.js";
9
+ import { bindOutput } from "../wait/output-sink.js";
9
10
  import { cssPropertyName, IS_CHECKED_BODY } from "./element-reads.js";
10
11
  import { clickRect, DEFAULT_PAUSE_MS, NAMED_KEYS, pause, pressKey, typeText, } from "./input-actions.js";
11
12
  import { CLICK_CHECKS, FILL_CHECKS, PRESS_CHECKS, SCREENSHOT_CHECKS, ATTACHED_ONLY, } from "./page-scripts.js";
@@ -347,6 +348,10 @@ export default class Locator {
347
348
  }
348
349
  /** Waits until the field is editable and not covered, then replaces its value by typing `value`; an empty string clears it */
349
350
  async fill(value, options) {
351
+ // Taken when the call starts, so a fill that outlives its test saves into that test's closed sink
352
+ const save = options?.output === undefined
353
+ ? undefined
354
+ : bindOutput("fill()", options.output);
350
355
  const state = await this.waitForActionable("fill", FILL_CHECKS, { scroll: true, hitTest: true }, options);
351
356
  // Typing over the selected existing value replaces it; Backspace clears it for an empty value
352
357
  const keys = value === "" ? NAMED_KEYS.Backspace : value;
@@ -355,6 +360,7 @@ export default class Locator {
355
360
  await this.wait();
356
361
  await this.callOnElement(undefined, `if (typeof el.select === "function") el.select();`);
357
362
  await typeText(this.biDiConnector, this.contextId, keys);
363
+ save?.(value);
358
364
  }
359
365
  }
360
366
  function booleanOrNull(value) {
@@ -1,12 +1,22 @@
1
1
  import { applyEdit } from "../steps/edit.js";
2
+ import { parseSpec } from "../steps/parse.js";
2
3
  /** Writes a recorder event into the spec source, as an edit of test number `test` */
3
4
  export function applyRecorderEvent(source, test, event) {
4
- return event.op === "remove"
5
- ? applyEdit(source, { op: "remove", test, index: event.index })
6
- : applyEdit(source, {
7
- op: event.op,
5
+ if (event.op === "remove")
6
+ return applyEdit(source, { op: "remove", test, index: event.index });
7
+ const { declare } = event.op === "insert" ? event : {};
8
+ const inputs = parseSpec(source)[test]?.inputs ?? [];
9
+ const declared = declare && !inputs.some((input) => input.name === declare.name)
10
+ ? applyEdit(source, {
11
+ op: "declare",
8
12
  test,
9
- index: event.index,
10
- step: event.step,
11
- });
13
+ inputs: [...inputs, declare],
14
+ })
15
+ : source;
16
+ return applyEdit(declared, {
17
+ op: event.op,
18
+ test,
19
+ index: event.index,
20
+ step: event.step,
21
+ });
12
22
  }
@@ -1,9 +1,12 @@
1
- import type { Expectation, FileEntry, LocatorSpec, PageExpectation, Step } from "../steps/model.js";
1
+ import type { Expectation, FileEntry, LocatorSpec, PageExpectation, Step, TestInput } from "../steps/model.js";
2
2
  /** What the recorder does to the step list: a new step at `index`, a step rewritten in place, or one taken out */
3
- export type RecorderEvent = {
3
+ export type RecorderEvent =
4
+ /** `declare`: an input the step reads, declared with its default before the step goes in */
5
+ {
4
6
  op: "insert";
5
7
  index: number;
6
8
  step: Step;
9
+ declare?: TestInput;
7
10
  } | {
8
11
  op: "replace";
9
12
  index: number;
@@ -42,6 +45,16 @@ export type Observation = {
42
45
  not: boolean;
43
46
  expectation: Expectation;
44
47
  }
48
+ /** The toolbar's Save as output: the element's text, or what `pattern` captures of it */
49
+ | {
50
+ type: "saveOutput";
51
+ locator: LocatorSpec;
52
+ name: string;
53
+ pattern?: {
54
+ source: string;
55
+ flags: string;
56
+ };
57
+ }
45
58
  /** The toolbar's Page panel: the page's URL or title */
46
59
  | {
47
60
  type: "expectPage";
@@ -51,9 +64,11 @@ export type Observation = {
51
64
  type: "insert";
52
65
  locator: LocatorSpec;
53
66
  value: {
54
- kind: "env" | "secret";
67
+ kind: "env" | "secret" | "input";
55
68
  name: string;
56
69
  };
70
+ /** A new input: its default */
71
+ declare?: string;
57
72
  }
58
73
  /** A file input got a click: the click recorded just before it opened the file picker */
59
74
  | {
@@ -20,10 +20,15 @@ export class Normaliser {
20
20
  get nextIndex() {
21
21
  return this.next;
22
22
  }
23
- insert(step) {
23
+ insert(step, declare) {
24
24
  this.lastStep = step;
25
25
  this.picker = undefined;
26
- return { op: "insert", index: this.next++, step };
26
+ return {
27
+ op: "insert",
28
+ index: this.next++,
29
+ step,
30
+ ...(declare && { declare }),
31
+ };
27
32
  }
28
33
  /** The path (and query) of `url` when it is inside the base URL, else `url` */
29
34
  relative(url) {
@@ -47,6 +52,13 @@ export class Normaliser {
47
52
  not: observation.not,
48
53
  expectation: observation.expectation,
49
54
  };
55
+ case "saveOutput":
56
+ return {
57
+ kind: "saveOutput",
58
+ name: observation.name,
59
+ locator: observation.locator,
60
+ ...(observation.pattern && { pattern: observation.pattern }),
61
+ };
50
62
  case "expectPage":
51
63
  return {
52
64
  kind: "expectPage",
@@ -79,7 +91,10 @@ export class Normaliser {
79
91
  const fromToolbar = this.toolbarStep(observation);
80
92
  if (fromToolbar) {
81
93
  this.lastFill = undefined;
82
- return [this.insert(fromToolbar)];
94
+ const declare = observation.type === "insert" && observation.declare !== undefined
95
+ ? { name: observation.value.name, default: observation.declare }
96
+ : undefined;
97
+ return [this.insert(fromToolbar, declare)];
83
98
  }
84
99
  switch (observation.type) {
85
100
  case "picker":