@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
@@ -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);
@@ -1,8 +1,11 @@
1
1
  import type { PickInfo } from "../protocol.js";
2
2
  /** Whether a text holds the value of one of the secrets */
3
3
  export declare function holdsSecret(secrets: Map<string, string | undefined>): (text: string | undefined) => boolean;
4
+ /** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
5
+ export declare function maskSecrets(secrets: Map<string, string | undefined>): (text: string) => string;
4
6
  /**
5
7
  * A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
8
+ * the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
6
9
  * and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
7
10
  */
8
11
  export declare function withoutSecrets(info: PickInfo & {
@@ -1,17 +1,27 @@
1
+ import { MASK } from "../../../config/mask.js";
1
2
  import { expectationFor } from "../../expectation.js";
2
3
  /** Whether a text holds the value of one of the secrets */
3
4
  export function holdsSecret(secrets) {
4
5
  const values = [...secrets.values()].filter((value) => value !== undefined && value !== "");
5
6
  return (text) => text !== undefined && values.some((value) => text.includes(value));
6
7
  }
8
+ /** A text with every secret value it holds replaced by the mask, longest value first (as `holdsSecret` finds them) */
9
+ export function maskSecrets(secrets) {
10
+ const values = [...secrets.values()]
11
+ .filter((value) => value !== undefined && value !== "")
12
+ .sort((a, b) => b.length - a.length);
13
+ return (text) => values.reduce((masked, value) => masked.split(value).join(MASK), text);
14
+ }
7
15
  /**
8
16
  * A field may hold an inserted secret: its value (and any text, attribute, class or CSS value holding a secret) is left out,
17
+ * the output text has the secret masked (Save as output previews on the recorder's side then, see `outputPreview`),
9
18
  * and the first matcher follows the Alt+click rule without a value, so neither the page nor the spec gets it.
10
19
  */
11
20
  export function withoutSecrets(info, secrets) {
12
21
  const secret = holdsSecret(secrets);
13
22
  if (!secret(info.value) &&
14
23
  !secret(info.text) &&
24
+ !secret(info.outputText) &&
15
25
  !secret(info.first.expected) &&
16
26
  !secret(info.className ?? undefined) &&
17
27
  !secret(info.css.value) &&
@@ -23,6 +33,8 @@ export function withoutSecrets(info, secrets) {
23
33
  return {
24
34
  ...rest,
25
35
  text,
36
+ outputText: maskSecrets(secrets)(info.outputText),
37
+ ...(secret(info.outputText) && { showsSecret: true }),
26
38
  attributes: info.attributes.filter((a) => !secret(a.value)),
27
39
  className: secret(info.className ?? undefined) ? null : info.className,
28
40
  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
+ }
@@ -0,0 +1,15 @@
1
+ import type { ActionReply, OutputPreview } from "../protocol.js";
2
+ import type { ActionContext, Of } from "../types.js";
3
+ /**
4
+ * What Save as output would save from an element that shows a secret, worked out here because the page only has
5
+ * its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
6
+ * as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
7
+ */
8
+ export declare function outputPreview(ctx: ActionContext, message: Of<"output-preview">): OutputPreview;
9
+ /**
10
+ * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
11
+ * name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
12
+ * or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
13
+ * secret is saved too: the panel warns that the output may hold it.
14
+ */
15
+ export declare function saveOutputAction(ctx: ActionContext, message: Of<"save-output">): Promise<ActionReply>;
@@ -0,0 +1,73 @@
1
+ import { captured, matchesText } from "../../../assert/text-match.js";
2
+ import { holdsSecret, maskSecrets } from "./pick.js";
3
+ import { fail, nameProblem, PAUSED } from "./reply.js";
4
+ const PICK_AGAIN = "Pick the element again";
5
+ /** A pattern as typed, compiled the way the step writes it; undefined when there is none, an error when it isn't valid */
6
+ function compile(pattern) {
7
+ const source = String(pattern ?? "").trim();
8
+ if (source === "")
9
+ return undefined;
10
+ try {
11
+ return new RegExp(source);
12
+ }
13
+ catch {
14
+ return { error: "pattern is not a valid regular expression" };
15
+ }
16
+ }
17
+ /**
18
+ * What Save as output would save from an element that shows a secret, worked out here because the page only has
19
+ * its text masked: `outputs.save`'s rule (the first group, else the whole match, else the whole text) on the text
20
+ * as picked, every secret masked, and whether the value held one. The errors read as the panel's own preview's.
21
+ */
22
+ export function outputPreview(ctx, message) {
23
+ const pattern = String(message.pattern ?? "");
24
+ const answer = { preview: true, pattern };
25
+ const pick = ctx.pick();
26
+ if (!pick || pick.id !== message.id)
27
+ return { ...answer, ok: false, error: PICK_AGAIN };
28
+ const compiled = compile(pattern);
29
+ if (compiled && !(compiled instanceof RegExp))
30
+ return { ...answer, ok: false, error: "Not a valid pattern" };
31
+ if (compiled && !matchesText(pick.text, compiled, "exact"))
32
+ return { ...answer, ok: false, error: "No match" };
33
+ const value = compiled ? captured(compiled, pick.text) : pick.text;
34
+ return {
35
+ ...answer,
36
+ ok: true,
37
+ value: maskSecrets(ctx.secrets)(value),
38
+ secret: holdsSecret(ctx.secrets)(value),
39
+ };
40
+ }
41
+ /**
42
+ * Turns the Save as output panel's choice into a `saveOutput` step for the element that was picked. Refuses a
43
+ * name `test()` would reject, an element with no text when there is no pattern, and a pattern that isn't valid
44
+ * or doesn't match the element's text as picked, read the way `outputs.save` reads it. An element that shows a
45
+ * secret is saved too: the panel warns that the output may hold it.
46
+ */
47
+ export async function saveOutputAction(ctx, message) {
48
+ if (ctx.isPaused())
49
+ return fail(PAUSED);
50
+ const pick = ctx.pick();
51
+ if (!pick || pick.id !== message.id)
52
+ return fail(PICK_AGAIN);
53
+ const name = String(message.name ?? "").trim();
54
+ const problem = nameProblem(name);
55
+ if (problem)
56
+ return fail(problem, "name");
57
+ const pattern = compile(message.pattern);
58
+ if (pattern && !(pattern instanceof RegExp))
59
+ return fail(pattern.error);
60
+ if (pattern && !matchesText(pick.text, pattern, "exact"))
61
+ return fail("pattern doesn't match the element's text");
62
+ if (!pattern && pick.text === "")
63
+ return fail("the element has no text");
64
+ ctx.setPick(undefined);
65
+ ctx.deps.observe({
66
+ type: "saveOutput",
67
+ locator: pick.locator,
68
+ name,
69
+ // The built pattern's source: a `/` comes out escaped, so the spec it is written into compiles
70
+ ...(pattern && { pattern: { source: pattern.source, flags: "" } }),
71
+ });
72
+ return { ok: true };
73
+ }
@@ -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 { outputPreview, 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,10 @@ 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));
151
+ case "output-preview":
152
+ return this.reply(message.id, outputPreview(this.actions, message));
149
153
  case "wait":
150
154
  if (this.paused)
151
155
  return;
@@ -278,7 +282,7 @@ export class ToolbarController {
278
282
  }
279
283
  /**
280
284
  * 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
285
+ * `insert`, `add-insertable` or `save-output` whose id is in flight or was already answered `{ ok: true }` is a double click and is
282
286
  * dropped (its first copy answers). A retry after `{ ok: false }` is accepted.
283
287
  */
284
288
  accept(message) {
@@ -319,7 +323,12 @@ export class ToolbarController {
319
323
  return undefined;
320
324
  const inspected = await this.deps.inspect(message.target, locator, message.candidates);
321
325
  if (page === this.scope.current)
322
- this.pick = { id: message.id, locator, counts: inspected.counts };
326
+ this.pick = {
327
+ id: message.id,
328
+ locator,
329
+ counts: inspected.counts,
330
+ text: inspected.info.outputText,
331
+ };
323
332
  return inspected;
324
333
  });
325
334
  }
@@ -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,32 @@ 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
+ }
121
+ /** The pattern of Save as output for an element that shows a secret, as typed: answered with an `OutputPreview` */
122
+ | {
123
+ action: "output-preview";
124
+ id: number;
125
+ pattern: string;
126
+ }
107
127
  /** The Page panel opened: answered with a `PageInfo` */
108
128
  | {
109
129
  action: "page-info";
@@ -130,7 +150,12 @@ export type ToolbarMessage = {
130
150
  export type PickInfo = {
131
151
  ok: true;
132
152
  label: string;
153
+ /** Whitespace collapsed and trimmed, cut at 4 KB: what the Assert panel shows and prefills */
133
154
  text: string;
155
+ /** The whole text as `outputs.save` reads it (`textContent`, trimmed): what Save as output captures from */
156
+ outputText: string;
157
+ /** `outputText` held a secret, which is masked in it: Save as output asks the recorder for its preview */
158
+ showsSecret?: true;
134
159
  value?: string;
135
160
  attributes: {
136
161
  name: string;
@@ -169,7 +194,22 @@ export type PageInfo = {
169
194
  ok: false;
170
195
  error: string;
171
196
  };
172
- /** The answer to `insert`, `add-insertable`, `assert` and `assert-page` */
197
+ /**
198
+ * The answer to `output-preview`: what `outputs.save` would keep from the element's real text with `pattern`,
199
+ * every secret masked; `secret` when the value held one. `pattern` is the one asked about, so a late answer is dropped.
200
+ */
201
+ export type OutputPreview = {
202
+ preview: true;
203
+ pattern: string;
204
+ } & ({
205
+ ok: true;
206
+ value: string;
207
+ secret: boolean;
208
+ } | {
209
+ ok: false;
210
+ error: string;
211
+ });
212
+ /** The answer to `insert`, `add-insertable`, `assert`, `assert-page` and `save-output` */
173
213
  export type ActionReply = {
174
214
  ok: true;
175
215
  } | {
@@ -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,17 @@ 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 {
20
+ ui.output.error = null;
21
+ if (ui.output.info && ui.output.info.ok && ui.output.info.showsSecret) askPreview(ui.output);
22
+ }
23
+ render();
24
+ }
14
25
  else if (field === "attribute" && ui.assert) {
15
26
  ui.assert.attribute = value;
16
27
  ui.assert.expected = prefill(ui.assert, "toHaveAttribute");