@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
@@ -0,0 +1,9 @@
1
+ import type { ActionReply } from "../protocol.js";
2
+ import type { ActionContext, Of } from "../types.js";
3
+ /**
4
+ * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses an
5
+ * element whose text holds a secret, a name `test()` would reject, an element with no text when there is no
6
+ * pattern, and a pattern that isn't valid or doesn't match the element's text as picked, read the way
7
+ * `outputs.save` reads it.
8
+ */
9
+ export declare function saveOutputAction(ctx: ActionContext, message: Of<"save-output">): Promise<ActionReply>;
@@ -0,0 +1,46 @@
1
+ import { matchesText } from "../../../assert/text-match.js";
2
+ import { holdsSecret } from "./pick.js";
3
+ import { fail, nameProblem, PAUSED } from "./reply.js";
4
+ /**
5
+ * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses an
6
+ * element whose text holds a secret, a name `test()` would reject, an element with no text when there is no
7
+ * pattern, and a pattern that isn't valid or doesn't match the element's text as picked, read the way
8
+ * `outputs.save` reads it.
9
+ */
10
+ export async function saveOutputAction(ctx, message) {
11
+ if (ctx.isPaused())
12
+ return fail(PAUSED);
13
+ const pick = ctx.pick();
14
+ if (!pick || pick.id !== message.id)
15
+ return fail("Pick the element again");
16
+ // The step would write the secret into the run's results
17
+ if (holdsSecret(ctx.secrets)(pick.text))
18
+ return fail("this element shows a secret; it can't be an output");
19
+ const name = String(message.name ?? "").trim();
20
+ const problem = nameProblem(name);
21
+ if (problem)
22
+ return fail(problem, "name");
23
+ const source = String(message.pattern ?? "").trim();
24
+ let pattern;
25
+ if (source !== "") {
26
+ try {
27
+ pattern = new RegExp(source);
28
+ }
29
+ catch {
30
+ return fail("pattern is not a valid regular expression");
31
+ }
32
+ if (!matchesText(pick.text, pattern, "exact"))
33
+ return fail("pattern doesn't match the element's text");
34
+ }
35
+ else if (pick.text === "")
36
+ return fail("the element has no text");
37
+ ctx.setPick(undefined);
38
+ ctx.deps.observe({
39
+ type: "saveOutput",
40
+ locator: pick.locator,
41
+ name,
42
+ // The built pattern's source: a `/` comes out escaped, so the spec it is written into compiles
43
+ ...(pattern && { pattern: { source: pattern.source, flags: "" } }),
44
+ });
45
+ return { ok: true };
46
+ }
@@ -18,8 +18,8 @@ export type Message = ToolbarMessage & {
18
18
  target?: ElementHandle;
19
19
  };
20
20
  export declare const msg: (message: Record<string, unknown>) => Message;
21
- export declare const insert: (id: number, kind: "env" | "secret", name: string) => Message;
22
- export declare const add: (id: number, kind: "env" | "secret", name: string, value: string) => Message;
21
+ export declare const insert: (id: number, kind: "env" | "secret" | "input", name: string) => Message;
22
+ export declare const add: (id: number, kind: "env" | "secret" | "input", name: string, value: string) => Message;
23
23
  export declare const pick: (id: number) => Message;
24
24
  /** A controller over fakes that record everything */
25
25
  export declare function setup(options?: Partial<ControllerOptions>, overrides?: Partial<ControllerDeps>): {
@@ -10,6 +10,7 @@ const OPTIONS = {
10
10
  secrets: { ADMIN_PASSWORD: SECRET, API_TOKEN: undefined },
11
11
  secretsIgnored: true,
12
12
  screenshots: 0,
13
+ inputs: { email: "a@b.c" },
13
14
  };
14
15
  export const TARGET = new ElementHandle("el-1");
15
16
  export const CANDIDATES = [
@@ -24,6 +25,7 @@ export const INFO = {
24
25
  ok: true,
25
26
  label: `textbox "Email"`,
26
27
  text: "",
28
+ outputText: "",
27
29
  value: "a@b.c",
28
30
  attributes: [{ name: "id", value: "email" }],
29
31
  first: { matcher: "toHaveValue", expected: "a@b.c" },
@@ -15,6 +15,7 @@ export declare class ToolbarController {
15
15
  private screenshots;
16
16
  private readonly variables;
17
17
  private readonly secrets;
18
+ private readonly inputs;
18
19
  private pausedAt;
19
20
  /** The URL the resume question offers; set while it is asked */
20
21
  private question;
@@ -70,7 +71,7 @@ export declare class ToolbarController {
70
71
  private answer;
71
72
  /**
72
73
  * Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `assert-page`,
73
- * `insert` or `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
74
+ * `insert`, `add-insertable` or `save-output` whose id is in flight or was already answered `{ ok: true }` is a double click and is
74
75
  * dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
75
76
  */
76
77
  accept(message: Message): boolean;
@@ -4,7 +4,9 @@ import { assertAction } from "./actions/assert.js";
4
4
  import { assertPageAction, pageInfoOf } from "./actions/assert-page.js";
5
5
  import { addInsertableAction, insertAction } from "./actions/insert.js";
6
6
  import { withoutSecrets } from "./actions/pick.js";
7
+ import { saveOutputAction } from "./actions/save-output.js";
7
8
  import { fail, PAUSED, UNIDENTIFIABLE } from "./actions/reply.js";
9
+ import { insertablesOf } from "./insertables.js";
8
10
  import { LEFT, PageScope } from "./page-scope.js";
9
11
  /** A wait for time is 0.1–60 s */
10
12
  const WAIT_MIN_MS = 100;
@@ -39,11 +41,13 @@ export class ToolbarController {
39
41
  String(value),
40
42
  ]));
41
43
  this.secrets = new Map(Object.entries(options.secrets));
44
+ this.inputs = new Map(Object.entries(options.inputs ?? {}));
42
45
  this.actions = {
43
46
  deps,
44
47
  options,
45
48
  variables: this.variables,
46
49
  secrets: this.secrets,
50
+ inputs: this.inputs,
47
51
  isPaused: () => this.paused,
48
52
  pick: () => this.pick,
49
53
  setPick: (pick) => {
@@ -64,11 +68,7 @@ export class ToolbarController {
64
68
  steps: this.steps,
65
69
  environment: this.options.environment,
66
70
  canAddVariables: this.options.canAddVariables,
67
- variables: [...this.variables].map(([name, value]) => ({ name, value })),
68
- secrets: [...this.secrets].map(([name, value]) => ({
69
- name,
70
- set: value !== undefined,
71
- })),
71
+ ...insertablesOf(this.actions),
72
72
  secretsIgnored: this.options.secretsIgnored,
73
73
  resumeQuestion: this.question !== undefined,
74
74
  nextScreenshot: `screenshot-${this.screenshots + 1}`,
@@ -146,6 +146,8 @@ export class ToolbarController {
146
146
  return this.replying(message, () => insertAction(this.actions, message));
147
147
  case "add-insertable":
148
148
  return this.replying(message, () => addInsertableAction(this.actions, message));
149
+ case "save-output":
150
+ return this.replying(message, () => saveOutputAction(this.actions, message));
149
151
  case "wait":
150
152
  if (this.paused)
151
153
  return;
@@ -278,7 +280,7 @@ export class ToolbarController {
278
280
  }
279
281
  /**
280
282
  * Whether to process `message`; call it as the message arrives, before queueing it. An `assert`, `assert-page`,
281
- * `insert` or `add-insertable` whose id is in flight or was already answered `{ ok: true }` is a double click and is
283
+ * `insert`, `add-insertable` or `save-output` whose id is in flight or was already answered `{ ok: true }` is a double click and is
282
284
  * dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
283
285
  */
284
286
  accept(message) {
@@ -319,7 +321,12 @@ export class ToolbarController {
319
321
  return undefined;
320
322
  const inspected = await this.deps.inspect(message.target, locator, message.candidates);
321
323
  if (page === this.scope.current)
322
- this.pick = { id: message.id, locator, counts: inspected.counts };
324
+ this.pick = {
325
+ id: message.id,
326
+ locator,
327
+ counts: inspected.counts,
328
+ text: inspected.info.outputText,
329
+ };
323
330
  return inspected;
324
331
  });
325
332
  }
@@ -0,0 +1,7 @@
1
+ import type { ToolbarState } from "./protocol.js";
2
+ /** What the Insert list shows of the variables, secrets (names only) and inputs the controller holds */
3
+ export declare function insertablesOf(held: {
4
+ variables: ReadonlyMap<string, string>;
5
+ secrets: ReadonlyMap<string, string | undefined>;
6
+ inputs: ReadonlyMap<string, string>;
7
+ }): Pick<ToolbarState, "variables" | "secrets" | "inputs">;
@@ -0,0 +1,12 @@
1
+ /** What the Insert list shows of the variables, secrets (names only) and inputs the controller holds */
2
+ export function insertablesOf(held) {
3
+ const named = ([name, value]) => ({ name, value });
4
+ return {
5
+ variables: [...held.variables].map(named),
6
+ secrets: [...held.secrets].map(([name, value]) => ({
7
+ name,
8
+ set: value !== undefined,
9
+ })),
10
+ inputs: [...held.inputs].map(named),
11
+ };
12
+ }
@@ -8,7 +8,7 @@ const MAX_COUNTS = 3;
8
8
  /** The property Has CSS starts with */
9
9
  const CSS_PROPERTY = "color";
10
10
  /**
11
- * Run in the toolbar's sandbox on the picked element: its text (cut at 4 KB), value (as the Alt+click rule reads it), first
11
+ * Run in the toolbar's sandbox on the picked element: its text (cut at 4 KB), its whole text as `outputs.save` reads it, value (as the Alt+click rule reads it), first
12
12
  * 20 attributes, its role without a name and its `tag.class` selector. `roleOf` only knows the roles the
13
13
  * candidates use, so the common list and table roles are added for Count. Also whether it can be checked or
14
14
  * disabled, its class attribute and the computed value of `CSS_PROPERTY`.
@@ -27,6 +27,7 @@ const INSPECT_SOURCE = String.raw `(el, cssName) => {
27
27
  const className = el.getAttribute("class");
28
28
  return {
29
29
  text: norm(String(el.textContent == null ? "" : el.textContent).slice(0, TEXT_CAP * 2)).slice(0, TEXT_CAP),
30
+ outputText: String(el.textContent == null ? "" : el.textContent).trim(),
30
31
  value: hasValue && !secret ? el.value : null,
31
32
  attributes: Array.from(el.attributes).slice(0, 20).map((a) => ({ name: a.name, value: a.value })),
32
33
  role: roleOf(el) || MORE_ROLES[tag] || null,
@@ -94,6 +95,7 @@ export async function inspectElement(page, connector, context, target, locator,
94
95
  ok: true,
95
96
  label: describeCall(locator.chain[0]),
96
97
  text: found.text,
98
+ outputText: found.outputText,
97
99
  ...(value !== undefined && { value }),
98
100
  attributes: found.attributes,
99
101
  first: {
@@ -25,6 +25,11 @@ export interface ToolbarState {
25
25
  name: string;
26
26
  set: boolean;
27
27
  }[];
28
+ /** The recording test's declared inputs and their defaults */
29
+ inputs: {
30
+ name: string;
31
+ value: string;
32
+ }[];
28
33
  /** Whether `.env.<environment>` is git-ignored */
29
34
  secretsIgnored: boolean;
30
35
  /** Ask "Add a step that opens this page?" */
@@ -93,17 +98,26 @@ export type ToolbarMessage = {
93
98
  id: number;
94
99
  target: unknown;
95
100
  candidates: LocatorCall[];
96
- kind: "env" | "secret";
101
+ kind: "env" | "secret" | "input";
97
102
  name: string;
98
103
  } | {
99
104
  action: "add-insertable";
100
105
  id: number;
101
106
  target: unknown;
102
107
  candidates: LocatorCall[];
103
- kind: "env" | "secret";
108
+ kind: "env" | "secret" | "input";
104
109
  name: string;
105
110
  value: string;
106
111
  }
112
+ /** Save as output for the element picked with the same id; `pattern` is a RegExp's source, no flags */
113
+ | {
114
+ action: "save-output";
115
+ id: number;
116
+ target: unknown;
117
+ candidates: LocatorCall[];
118
+ name: string;
119
+ pattern?: string;
120
+ }
107
121
  /** The Page panel opened: answered with a `PageInfo` */
108
122
  | {
109
123
  action: "page-info";
@@ -130,7 +144,10 @@ export type ToolbarMessage = {
130
144
  export type PickInfo = {
131
145
  ok: true;
132
146
  label: string;
147
+ /** Whitespace collapsed and trimmed, cut at 4 KB: what the Assert panel shows and prefills */
133
148
  text: string;
149
+ /** The whole text as `outputs.save` reads it (`textContent`, trimmed): what Save as output captures from */
150
+ outputText: string;
134
151
  value?: string;
135
152
  attributes: {
136
153
  name: string;
@@ -169,7 +186,7 @@ export type PageInfo = {
169
186
  ok: false;
170
187
  error: string;
171
188
  };
172
- /** The answer to `insert`, `add-insertable`, `assert` and `assert-page` */
189
+ /** The answer to `insert`, `add-insertable`, `assert`, `assert-page` and `save-output` */
173
190
  export type ActionReply = {
174
191
  ok: true;
175
192
  } | {
@@ -62,6 +62,7 @@ export const EVENTS_PAGE_JS = String.raw ` // ---- Events on the page (window c
62
62
  }, true);
63
63
  listen(window, "resize", () => render());
64
64
  listen(window, "scroll", () => {
65
- if (ui.panel === "assert" && ui.assert && !outline.hidden) showOutline(ui.assert.target);
65
+ const picked = ui.panel === "assert" ? ui.assert : ui.panel === "output" ? ui.output : null;
66
+ if (picked && !outline.hidden) showOutline(picked.target);
66
67
  }, true);
67
68
  `;
@@ -11,6 +11,14 @@ export const FIELDS_JS = String.raw ` const press = (action, detail) => {
11
11
  const value = t.value;
12
12
  if (field === "expected" && ui.assert) ui.assert.expected = value;
13
13
  else if (field === "pageExpected" && ui.page) ui.page.expected = value;
14
+ else if ((field === "outputName" || field === "outputPattern") && ui.output) {
15
+ const key = field === "outputName" ? "name" : "pattern";
16
+ if (ui.output[key] === value) return;
17
+ ui.output[key] = value;
18
+ if (key === "name") ui.output.nameError = null;
19
+ else ui.output.error = null;
20
+ render();
21
+ }
14
22
  else if (field === "attribute" && ui.assert) {
15
23
  ui.assert.attribute = value;
16
24
  ui.assert.expected = prefill(ui.assert, "toHaveAttribute");
@@ -19,14 +19,8 @@ export const HANDLERS_JS = String.raw ` // Every action is what a click on its
19
19
  expand: () => post({ action: "collapse", collapsed: false }),
20
20
  "resume-yes": () => { if (state.resumeQuestion) post({ action: "resume-goto", add: true }); },
21
21
  "resume-no": () => { if (state.resumeQuestion) post({ action: "resume-goto", add: false }); },
22
- assert: () => {
23
- if (!usable()) return;
24
- const wasOn = toolOn("assert");
25
- stopPicking();
26
- closePanel();
27
- ui.picking = !wasOn;
28
- render();
29
- },
22
+ assert: () => startPicking("assert"),
23
+ output: () => startPicking("output"),
30
24
  page: () => openTool("page"),
31
25
  insert: () => openTool("insert"),
32
26
  wait: () => openTool("wait"),
@@ -57,6 +51,17 @@ export const HANDLERS_JS = String.raw ` // Every action is what a click on its
57
51
  post(message);
58
52
  render();
59
53
  },
54
+ "output-save": () => {
55
+ const o = ui.panel === "output" && ui.output;
56
+ if (!o || ui.busy || !o.info || !o.info.ok) return;
57
+ ui.busy = true;
58
+ o.nameError = null;
59
+ o.error = null;
60
+ const message = { action: "save-output", id: o.id, target: o.target, candidates: o.candidates, name: o.name.trim() };
61
+ if (o.pattern.trim()) message.pattern = o.pattern.trim();
62
+ post(message);
63
+ render();
64
+ },
60
65
  "page-matcher": (detail) => {
61
66
  const p = ui.panel === "page" && ui.page;
62
67
  if (!p || !p.info || !p.info.ok || !PAGE_MATCHERS.some(([matcher]) => matcher === detail.matcher)) return;
@@ -85,6 +90,8 @@ export const HANDLERS_JS = String.raw ` // Every action is what a click on its
85
90
  const secret = state.secrets.find((s) => s.name === detail.name);
86
91
  if (!secret) return;
87
92
  if (!secret.set) return openForm("secret", secret.name, true);
93
+ } else if (detail.kind === "input") {
94
+ if (!(state.inputs || []).some((i) => i.name === detail.name)) return;
88
95
  } else if (!state.canAddVariables || !state.variables.some((v) => v.name === detail.name)) {
89
96
  return;
90
97
  }
@@ -93,20 +100,20 @@ export const HANDLERS_JS = String.raw ` // Every action is what a click on its
93
100
  "insert-first": () => {
94
101
  if (ui.panel !== "insert" || !ui.insert.field) return;
95
102
  const found = matches();
96
- const first = found.variables[0] ? { kind: "env", name: found.variables[0].name } : found.secrets[0] && { kind: "secret", name: found.secrets[0].name };
103
+ const first = found.variables[0] ? { kind: "env", name: found.variables[0].name } : found.secrets[0] ? { kind: "secret", name: found.secrets[0].name } : found.inputs[0] && { kind: "input", name: found.inputs[0].name };
97
104
  if (first) handlers["insert-item"](first);
98
105
  },
99
106
  "insert-new": (detail) => {
100
107
  if (ui.panel !== "insert" || !ui.insert.field) return;
101
108
  const filter = ui.insert.filter.trim();
102
109
  const base = filter || fieldName(ui.insert.field);
103
- const name = detail.kind === "secret" ? snake(base) : filter ? variableName(filter) : camel(base);
104
- openForm(detail.kind === "secret" ? "secret" : "env", name, false);
110
+ const name = detail.kind === "secret" ? snake(base) : detail.kind === "input" ? camel(base) : filter ? variableName(filter) : camel(base);
111
+ openForm(detail.kind === "secret" ? "secret" : detail.kind === "input" ? "input" : "env", name, false);
105
112
  },
106
113
  "new-kind": (detail) => {
107
114
  const f = ui.panel === "new" && ui.form;
108
115
  if (!f || ui.busy || f.kind === detail.kind) return;
109
- f.kind = detail.kind === "secret" ? "secret" : "env";
116
+ f.kind = detail.kind === "secret" ? "secret" : detail.kind === "input" ? "input" : "env";
110
117
  f.name = f.kind === "secret" ? snake(f.name) : camel(f.name);
111
118
  f.nameError = null;
112
119
  f.error = null;
@@ -3,6 +3,7 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
3
3
  const closePanel = () => {
4
4
  ui.panel = null;
5
5
  ui.assert = null;
6
+ ui.output = null;
6
7
  ui.page = null;
7
8
  ui.busy = false;
8
9
  ui.closeOnUpdate = false;
@@ -11,7 +12,7 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
11
12
  };
12
13
  const stopPicking = () => {
13
14
  ui.picking = false;
14
- if (ui.panel !== "assert") hideOutline();
15
+ if (ui.panel !== "assert" && ui.panel !== "output") hideOutline();
15
16
  };
16
17
  const usable = () => !paused() && !state.collapsed;
17
18
  const openTool = (tool) => {
@@ -43,6 +44,16 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
43
44
  }
44
45
  render();
45
46
  };
47
+ // The tool's picker: Assert or Save as output
48
+ const startPicking = (tool) => {
49
+ if (!usable()) return;
50
+ const wasOn = toolOn(tool);
51
+ stopPicking();
52
+ closePanel();
53
+ ui.picking = !wasOn;
54
+ ui.pickFor = tool;
55
+ render();
56
+ };
46
57
  const pick = (target) => {
47
58
  if (!ui.picking || !target || target.nodeType !== 1) return;
48
59
  ui.picking = false;
@@ -50,12 +61,16 @@ export const PICKING_JS = String.raw ` // ---- Panels and picking ----
50
61
  const id = nextId++;
51
62
  const candidates = candidatesOf(target);
52
63
  const r = target.getBoundingClientRect();
53
- ui.assert = {
54
- id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
55
- cssName: "", cssValue: "", not: false, error: null,
56
- rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
57
- };
58
- ui.panel = "assert";
64
+ const rect = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
65
+ if (ui.pickFor === "output") {
66
+ ui.output = { id, target, candidates, info: null, name: "", pattern: "", nameError: null, error: null, rect };
67
+ } else {
68
+ ui.assert = {
69
+ id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0,
70
+ cssName: "", cssValue: "", not: false, error: null, rect,
71
+ };
72
+ }
73
+ ui.panel = ui.pickFor === "output" ? "output" : "assert";
59
74
  ui.pending = id;
60
75
  showOutline(target);
61
76
  post({ action: "pick", id, target, candidates });
@@ -41,6 +41,9 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
41
41
  a.cssValue = result.css.value;
42
42
  a.expected = prefill(a, a.matcher);
43
43
  }
44
+ } else if (ui.panel === "output" && ui.output && !ui.output.info) {
45
+ ui.output.info = result;
46
+ if (ok) ui.focus = "outputName";
44
47
  } else if (ui.panel === "page" && ui.page && !ui.page.info) {
45
48
  ui.page.info = result;
46
49
  if (ok) {
@@ -51,6 +54,9 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
51
54
  closePanel();
52
55
  } else if (ui.panel === "assert" && ui.assert) {
53
56
  ui.assert.error = result.error;
57
+ } else if (ui.panel === "output" && ui.output) {
58
+ if (result.field === "name") ui.output.nameError = result.error;
59
+ else ui.output.error = result.error;
54
60
  } else if (ui.panel === "page" && ui.page) {
55
61
  ui.page.error = result.error;
56
62
  } else if (ui.panel === "insert") {
@@ -14,7 +14,7 @@ export const SETUP_JS = String.raw ` if (window !== window.top) return;
14
14
  const CSS = ${JSON.stringify(TOOLBAR_CSS)};
15
15
  const DEFAULTS = {
16
16
  status: "recording", collapsed: false, position: null, steps: 0, environment: "", canAddVariables: false,
17
- variables: [], secrets: [], secretsIgnored: true, resumeQuestion: false, nextScreenshot: "screenshot-1",
17
+ variables: [], secrets: [], inputs: [], secretsIgnored: true, resumeQuestion: false, nextScreenshot: "screenshot-1",
18
18
  warnings: [],
19
19
  };
20
20
  const MATCHERS = [
@@ -34,8 +34,8 @@ export const SETUP_JS = String.raw ` if (window !== window.top) return;
34
34
  let toastTimer = 0;
35
35
  // Local UI state; everything about steps lives in the recorder
36
36
  const ui = {
37
- panel: null, picking: false, busy: false, closeOnUpdate: false, pending: 0, focus: null,
38
- field: null, assert: null, page: null, insert: null, form: null,
37
+ panel: null, picking: false, pickFor: "assert", busy: false, closeOnUpdate: false, pending: 0, focus: null,
38
+ field: null, assert: null, output: null, page: null, insert: null, form: null,
39
39
  wait: { kind: "idle", seconds: "2" }, shot: { name: "", fullPage: false },
40
40
  drag: null, dropped: null, toastId: null,
41
41
  };
@@ -1,2 +1,2 @@
1
- /** Views: the Assert, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
1
+ /** Views: the Assert, Save as output, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
2
2
  export declare const VIEWS_PANELS_JS: string;
@@ -1,4 +1,4 @@
1
- /** Views: the Assert, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
1
+ /** Views: the Assert, Save as output, Page, Insert, New, Wait and Screenshot panels. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
2
2
  export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher) =>
3
3
  (matcher === "toHaveValue" && info.value === undefined) ||
4
4
  (matcher === "toHaveAttribute" && info.attributes.length === 0) ||
@@ -73,6 +73,38 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
73
73
  ];
74
74
  };
75
75
 
76
+ // What the output would hold, as outputs.save reads it: the pattern's first group, else its whole match, else the text
77
+ const captureOf = (text, pattern) => {
78
+ if (!pattern.trim()) return { value: text };
79
+ let re;
80
+ try { re = new RegExp(pattern.trim()); } catch (err) { return { error: "Not a valid pattern" }; }
81
+ const match = re.exec(text);
82
+ if (!match) return { error: "No match" };
83
+ return { value: match[1] !== undefined ? match[1] : match[0] };
84
+ };
85
+ const outputPanel = () => {
86
+ const o = ui.output;
87
+ const info = o.info;
88
+ const head = h("div", { class: "h" }, "Save as output", info && info.ok && h("code", { title: info.label }, info.label));
89
+ if (!info) return [head, h("div", { class: "msg" }, "…")];
90
+ if (!info.ok) return [head, h("div", { class: "err" }, info.error)];
91
+ const capture = captureOf(info.outputText, o.pattern);
92
+ return [
93
+ head,
94
+ h("div", { class: "lbl" }, "Text"),
95
+ h("div", { class: "msg out-text" }, info.text),
96
+ h("div", { class: "lbl" }, "Name"),
97
+ h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputName", value: o.name, "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
98
+ o.nameError && h("div", { class: "field-error" }, o.nameError),
99
+ h("div", { class: "lbl" }, "Pattern (optional)"),
100
+ h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "outputPattern", value: o.pattern, placeholder: "Order #(\\d+)", "data-enter": "output-save", autocomplete: "off", spellcheck: "false" })),
101
+ h("div", { class: "lbl" }, "Saves"),
102
+ capture.error ? h("div", { class: "warn out-capture" }, capture.error) : h("code", { class: "out-capture" }, capture.value),
103
+ o.error && h("div", { class: "err" }, o.error),
104
+ h("div", { class: "foot" }, h("button", { class: "add", "data-action": "output-save", disabled: ui.busy || !o.name.trim() }, "Save")),
105
+ ];
106
+ };
107
+
76
108
  const pagePanel = () => {
77
109
  const p = ui.page;
78
110
  const head = h("div", { class: "h" }, "Page");
@@ -98,13 +130,14 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
98
130
  filter,
99
131
  variables: state.canAddVariables ? state.variables.filter(hit) : [],
100
132
  secrets: state.secrets.filter(hit),
133
+ inputs: (state.inputs || []).filter(hit),
101
134
  };
102
135
  };
103
136
  const insertPanel = () => {
104
137
  const ins = ui.insert;
105
138
  if (!ins.field) return [h("div", { class: "h" }, "Insert"), h("div", { class: "msg" }, "Click into a field first")];
106
139
  const found = matches();
107
- const none = found.filter && found.variables.length === 0 && found.secrets.length === 0;
140
+ const none = found.filter && found.variables.length === 0 && found.secrets.length === 0 && found.inputs.length === 0;
108
141
  const item = (kind, name, value) =>
109
142
  h("button", { class: "it", "data-action": "insert-item", "data-kind": kind, "data-name": name, disabled: ui.busy }, h("code", null, name), value);
110
143
  return [
@@ -120,13 +153,19 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
120
153
  found.secrets.length
121
154
  ? found.secrets.map((s) => item("secret", s.name, s.set ? h("span", { class: "v" }, "••••••") : h("span", { class: "warn" }, "not set")))
122
155
  : h("div", { class: "empty" }, found.filter ? "No secret matches." : "No secrets yet."),
156
+ h("div", { class: "grp" }, "Inputs"),
157
+ found.inputs.length
158
+ ? found.inputs.map((i) => item("input", i.name, h("span", { class: "v", title: i.value }, i.value)))
159
+ : h("div", { class: "empty" }, found.filter ? "No input matches." : "No inputs yet."),
123
160
  ins.error && h("div", { class: "err" }, ins.error),
124
161
  h("button", { class: "act", "data-action": "insert-new", "data-kind": "env" }, "+ Add variable" + (none ? ' "' + variableName(ins.filter) + '"' : "")),
125
162
  h("button", { class: "act", "data-action": "insert-new", "data-kind": "secret" }, "+ Add secret" + (none ? ' "' + snake(ins.filter) + '"' : "")),
163
+ h("button", { class: "act", "data-action": "insert-new", "data-kind": "input" }, "+ Add input" + (none ? ' "' + camel(ins.filter) + '"' : "")),
126
164
  ];
127
165
  };
128
166
 
129
- const newHint = (secret, env, name) => {
167
+ const newHint = (kind, secret, env, name) => {
168
+ if (kind === "input") return "Declared in the test's inputs, with this value as its default.";
130
169
  if (!secret) return ["Saved to ", h("code", null, "environments." + env + ".variables"), " in samurai.config.ts. Other environments don't get it — add it there before running against them."];
131
170
  if (!state.secretsIgnored) return h("span", { class: "warn" }, "Add .env." + env + " to .gitignore before committing.");
132
171
  return ["Saved as ", h("code", null, "SAMURAI_SECRET_" + (name.trim() || "<NAME>")), " in ", h("code", null, ".env." + env), " (git-ignored)."];
@@ -134,22 +173,23 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
134
173
  const newPanel = () => {
135
174
  const f = ui.form;
136
175
  const secret = f.kind === "secret";
137
- const blocked = !secret && !state.canAddVariables;
176
+ const blocked = f.kind === "env" && !state.canAddVariables;
138
177
  const env = state.environment;
139
178
  return [
140
- h("div", { class: "h stack" }, f.setValue ? "Set value" : secret ? "New secret" : "New variable", h("small", null, 'For "' + fieldLabel(ui.insert.field) + '"')),
179
+ h("div", { class: "h stack" }, f.setValue ? "Set value" : secret ? "New secret" : f.kind === "input" ? "New input" : "New variable", h("small", null, 'For "' + fieldLabel(ui.insert.field) + '"')),
141
180
  h("div", { class: "seg" },
142
- h("button", { class: secret ? "" : "sel", "data-action": "new-kind", "data-kind": "env" }, "Variable"),
143
- h("button", { class: secret ? "sel" : "", "data-action": "new-kind", "data-kind": "secret" }, "Secret")),
181
+ h("button", { class: f.kind === "env" ? "sel" : "", "data-action": "new-kind", "data-kind": "env" }, "Variable"),
182
+ h("button", { class: secret ? "sel" : "", "data-action": "new-kind", "data-kind": "secret" }, "Secret"),
183
+ h("button", { class: f.kind === "input" ? "sel" : "", "data-action": "new-kind", "data-kind": "input" }, "Input")),
144
184
  blocked
145
185
  ? h("div", { class: "msg" }, "This recording has no environment")
146
186
  : [
147
187
  h("div", { class: "lbl" }, "Name"),
148
188
  h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "name", value: f.name, "data-enter": "new-save", autocomplete: "off", spellcheck: "false" })),
149
189
  f.nameError && h("div", { class: "field-error" }, f.nameError),
150
- h("div", { class: "lbl" }, "Value in " + env),
190
+ h("div", { class: "lbl" }, f.kind === "input" ? "Default value" : "Value in " + env),
151
191
  h("div", { class: "f" }, h("input", { type: secret ? "password" : "text", class: "in", "data-field": "value", value: f.value, "data-enter": "new-save", autocomplete: secret ? "new-password" : "off" })),
152
- h("div", { class: "hint" }, newHint(secret, env, f.name)),
192
+ h("div", { class: "hint" }, newHint(f.kind, secret, env, f.name)),
153
193
  f.error && h("div", { class: "err" }, f.error),
154
194
  ],
155
195
  h("div", { class: "foot plain" },
@@ -181,5 +221,5 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
181
221
  radio("fullPage", "true", ui.shot.fullPage, "Whole page"),
182
222
  h("div", { class: "foot" }, h("button", { class: "add", "data-action": "screenshot-add", disabled: ui.busy }, "Add")),
183
223
  ];
184
- const PANELS = { assert: assertPanel, page: pagePanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
224
+ const PANELS = { assert: assertPanel, output: outputPanel, page: pagePanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
185
225
  `;
@@ -1,7 +1,8 @@
1
1
  /** Views: the chip and the rail. A slice of the toolbar function: it shares one closure with its neighbours, see `../source.ts`. */
2
2
  export const VIEWS_RAIL_JS = String.raw ` // ---- Views ----
3
3
  const toolOn = (tool) =>
4
- tool === "assert" ? ui.picking || ui.panel === "assert" : tool === "insert" ? ui.panel === "insert" || ui.panel === "new" : ui.panel === tool;
4
+ tool === "assert" || tool === "output" ? (ui.picking && ui.pickFor === tool) || ui.panel === tool
5
+ : tool === "insert" ? ui.panel === "insert" || ui.panel === "new" : ui.panel === tool;
5
6
 
6
7
  const chipView = () =>
7
8
  h("div", { class: "chip" },
@@ -30,7 +31,9 @@ export const VIEWS_RAIL_JS = String.raw ` // ---- Views ----
30
31
  ? h("button", { class: "b on", "data-action": "resume" }, ico("▶"), "Resume")
31
32
  : h("button", { class: "b", "data-action": "pause" }, ico("❚❚"), "Pause"),
32
33
  tool("assert", "✓", "Assert"),
33
- ui.picking && h("div", { class: "note" }, "Click an element · Esc cancels"),
34
+ ui.picking && ui.pickFor === "assert" && h("div", { class: "note" }, "Click an element · Esc cancels"),
35
+ tool("output", "⇥", "Output"),
36
+ ui.picking && ui.pickFor === "output" && h("div", { class: "note" }, "Click an element · Esc cancels"),
34
37
  tool("page", "⌂", "Page"),
35
38
  tool("insert", "{x}", "Insert"),
36
39
  tool("wait", "⏱", "Wait"),