@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
@@ -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":
@@ -26,6 +26,10 @@ export async function toolbarOptions(run, source, dir, config, testIndex) {
26
26
  variables: run.settings.variables,
27
27
  secrets,
28
28
  secretsIgnored: await isGitIgnored(dir, `.env.${environment}`),
29
+ inputs: Object.fromEntries((tests[testIndex]?.inputs ?? []).map((input) => [
30
+ input.name,
31
+ input.default,
32
+ ])),
29
33
  screenshots: tests[testIndex]?.steps.filter(({ step }) => step.kind === "screenshot")
30
34
  .length ?? 0,
31
35
  };
@@ -1,13 +1,11 @@
1
1
  import type { ActionReply } from "./protocol.js";
2
- import type { Message, Of } from "./types.js";
3
- /** The messages that expect a reply, and that a double click sends twice */
4
- type Replying = Of<"assert" | "assert-page" | "insert" | "add-insertable">;
2
+ import type { Message, Replying } from "./types.js";
5
3
  /**
6
4
  * Drops the repeat of an action that is running or was answered `{ ok: true }` (a double click: its first copy
7
5
  * answers), and lets a retry after `{ ok: false }` through.
8
6
  */
9
7
  export declare class ActionGuard {
10
- /** Ids of `assert`, `assert-page`, `insert` and `add-insertable` being processed */
8
+ /** Ids of the replying actions (`REPLYING`) being processed */
11
9
  private readonly inFlight;
12
10
  /** Ids `accept` let through whose `run` hasn't started yet */
13
11
  private readonly claimed;
@@ -18,4 +16,3 @@ export declare class ActionGuard {
18
16
  /** Runs an action that expects a reply, unless it is a repeat (see `accept`); `reply` gets the answer */
19
17
  run(message: Replying, work: () => Promise<ActionReply>, reply: (id: number, result: ActionReply) => void): Promise<void>;
20
18
  }
21
- export {};
@@ -1,10 +1,17 @@
1
+ const REPLYING = [
2
+ "assert",
3
+ "assert-page",
4
+ "insert",
5
+ "add-insertable",
6
+ "save-output",
7
+ ];
1
8
  /**
2
9
  * Drops the repeat of an action that is running or was answered `{ ok: true }` (a double click: its first copy
3
10
  * answers), and lets a retry after `{ ok: false }` through.
4
11
  */
5
12
  export class ActionGuard {
6
13
  constructor() {
7
- /** Ids of `assert`, `assert-page`, `insert` and `add-insertable` being processed */
14
+ /** Ids of the replying actions (`REPLYING`) being processed */
8
15
  this.inFlight = new Set();
9
16
  /** Ids `accept` let through whose `run` hasn't started yet */
10
17
  this.claimed = new Set();
@@ -13,10 +20,7 @@ export class ActionGuard {
13
20
  }
14
21
  /** Whether to process `message`; call it as the message arrives, before queueing it */
15
22
  accept(message) {
16
- if (message.action !== "assert" &&
17
- message.action !== "assert-page" &&
18
- message.action !== "insert" &&
19
- message.action !== "add-insertable")
23
+ if (!REPLYING.includes(message.action))
20
24
  return true;
21
25
  const { id } = message;
22
26
  if (this.inFlight.has(id) || this.done.has(id))
@@ -1,6 +1,9 @@
1
1
  import type { ActionReply } from "../protocol.js";
2
2
  import type { ActionContext, Of } from "../types.js";
3
- /** Types a variable or secret the tester already has into the field they picked, and records one `insert` step */
3
+ /** Types a variable, secret or input the tester already has into the field they picked, and records one `insert` step */
4
4
  export declare function insertAction(ctx: ActionContext, message: Of<"insert">): Promise<ActionReply>;
5
- /** Saves a new variable or secret to the project, then types it into the field and records the `insert` step */
5
+ /**
6
+ * Adds a new variable, secret or input, then types it into the field and records the `insert` step. A variable or
7
+ * secret is saved to the project first; an input is declared by the step itself.
8
+ */
6
9
  export declare function addInsertableAction(ctx: ActionContext, message: Of<"add-insertable">): Promise<ActionReply>;
@@ -1,28 +1,39 @@
1
1
  import { ConfigEditError } from "../../../project/config-edit.js";
2
2
  import { SECRET_NAME } from "../../../project/secrets-file.js";
3
3
  import { ExistsError, VARIABLE_NAME } from "../../../project/variables.js";
4
- import { fail, NO_ENVIRONMENT, PAUSED, TYPE_FAILED, UNIDENTIFIABLE, } from "./reply.js";
5
- /** Types a variable or secret the tester already has into the field they picked, and records one `insert` step */
4
+ import { fail, nameProblem, NO_ENVIRONMENT, PAUSED, TYPE_FAILED, UNIDENTIFIABLE, } from "./reply.js";
5
+ /** Types a variable, secret or input the tester already has into the field they picked, and records one `insert` step */
6
6
  export async function insertAction(ctx, message) {
7
7
  if (ctx.isPaused())
8
8
  return fail(PAUSED);
9
9
  const { kind, name } = message;
10
- const value = kind === "secret" ? ctx.secrets.get(name) : ctx.variables.get(name);
10
+ const value = kind === "secret"
11
+ ? ctx.secrets.get(name)
12
+ : kind === "input"
13
+ ? ctx.inputs.get(name)
14
+ : ctx.variables.get(name);
11
15
  if (value === undefined)
12
16
  return fail(kind === "secret"
13
17
  ? `Set a value for ${name} first`
14
- : `There is no variable "${name}"`);
18
+ : kind === "input"
19
+ ? `There is no input "${name}"`
20
+ : `There is no variable "${name}"`);
15
21
  const locator = await locate(ctx, message);
16
22
  if (!locator)
17
23
  return fail(UNIDENTIFIABLE);
18
24
  return typeIn(ctx, locator, kind, name, value);
19
25
  }
20
- /** Saves a new variable or secret to the project, then types it into the field and records the `insert` step */
26
+ /**
27
+ * Adds a new variable, secret or input, then types it into the field and records the `insert` step. A variable or
28
+ * secret is saved to the project first; an input is declared by the step itself.
29
+ */
21
30
  export async function addInsertableAction(ctx, message) {
22
31
  if (ctx.isPaused())
23
32
  return fail(PAUSED);
24
33
  const { kind, name } = message;
25
34
  const value = String(message.value ?? "");
35
+ if (kind === "input")
36
+ return addInput(ctx, message, name, value);
26
37
  if (kind === "env" && !ctx.options.canAddVariables)
27
38
  return fail(NO_ENVIRONMENT);
28
39
  const known = kind === "env"
@@ -56,6 +67,26 @@ export async function addInsertableAction(ctx, message) {
56
67
  ctx.push();
57
68
  return typeIn(ctx, locator, kind, name, value, true);
58
69
  }
70
+ /**
71
+ * Types a new input's value and records the step with the declaration; the project is not written, the
72
+ * test's own `inputs` carries the default. The name counts as taken once the step is recorded.
73
+ */
74
+ async function addInput(ctx, message, name, value) {
75
+ const problem = nameProblem(name);
76
+ if (problem)
77
+ return fail(problem, "name");
78
+ if (ctx.inputs.has(name))
79
+ return fail(`"${name}" already exists`, "name");
80
+ const locator = await locate(ctx, message);
81
+ if (!locator)
82
+ return fail(UNIDENTIFIABLE);
83
+ const reply = await typeIn(ctx, locator, "input", name, value, false, true);
84
+ if (reply.ok) {
85
+ ctx.inputs.set(name, value);
86
+ ctx.push();
87
+ }
88
+ return reply;
89
+ }
59
90
  /** Undefined when `work` succeeded, else the reply for its error */
60
91
  async function write(work, replyFor) {
61
92
  try {
@@ -76,7 +107,7 @@ async function locate(ctx, message) {
76
107
  return undefined; // the element went away
77
108
  }
78
109
  }
79
- async function typeIn(ctx, locator, kind, name, value, saved = false) {
110
+ async function typeIn(ctx, locator, kind, name, value, saved = false, declare = false) {
80
111
  try {
81
112
  await ctx.deps.type(locator, value);
82
113
  }
@@ -86,7 +117,12 @@ async function typeIn(ctx, locator, kind, name, value, saved = false) {
86
117
  ctx.push();
87
118
  return fail(TYPE_FAILED);
88
119
  }
89
- ctx.deps.observe({ type: "insert", locator, value: { kind, name } });
120
+ ctx.deps.observe({
121
+ type: "insert",
122
+ locator,
123
+ value: { kind, name },
124
+ ...(declare && { declare: value }),
125
+ });
90
126
  return { ok: true };
91
127
  }
92
128
  const errorText = (err) => err instanceof Error ? err.message : String(err);
@@ -12,6 +12,7 @@ export function withoutSecrets(info, secrets) {
12
12
  const secret = holdsSecret(secrets);
13
13
  if (!secret(info.value) &&
14
14
  !secret(info.text) &&
15
+ !secret(info.outputText) &&
15
16
  !secret(info.first.expected) &&
16
17
  !secret(info.className ?? undefined) &&
17
18
  !secret(info.css.value) &&
@@ -23,6 +24,7 @@ export function withoutSecrets(info, secrets) {
23
24
  return {
24
25
  ...rest,
25
26
  text,
27
+ outputText: secret(info.outputText) ? "" : info.outputText,
26
28
  attributes: info.attributes.filter((a) => !secret(a.value)),
27
29
  className: secret(info.className ?? undefined) ? null : info.className,
28
30
  css: secret(info.css.value) ? { ...info.css, value: "" } : info.css,
@@ -5,3 +5,5 @@ export declare const NO_ENVIRONMENT = "This recording has no environment";
5
5
  export declare const PAUSED = "Nothing you do now is recorded.";
6
6
  /** A refusal the toolbar shows; `field` names the form field to mark */
7
7
  export declare const fail: (error: string, field?: "name") => ActionReply;
8
+ /** What is wrong with `name` as an input or output name, as the name field shows it; undefined when nothing is */
9
+ export declare function nameProblem(name: string): string | undefined;
@@ -1,3 +1,4 @@
1
+ import { IO_NAME, isIoName } from "../../../flows/core/steps.js";
1
2
  export const UNIDENTIFIABLE = "This element can't be identified uniquely";
2
3
  export const TYPE_FAILED = "Couldn't type into that field";
3
4
  export const NO_ENVIRONMENT = "This recording has no environment";
@@ -8,3 +9,11 @@ export const fail = (error, field) => ({
8
9
  error,
9
10
  ...(field && { field }),
10
11
  });
12
+ /** What is wrong with `name` as an input or output name, as the name field shows it; undefined when nothing is */
13
+ export function nameProblem(name) {
14
+ if (isIoName(name))
15
+ return undefined;
16
+ return IO_NAME.test(name)
17
+ ? `name: ${JSON.stringify(name)} is reserved`
18
+ : "name: use letters, digits and _";
19
+ }