@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
@@ -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: "", preview: null, 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 });
@@ -27,6 +27,13 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
27
27
  render();
28
28
  };
29
29
  const reply = (id, result) => {
30
+ // A Save as output preview, for the pattern still typed (a late one is dropped); it doesn't end a pending action
31
+ if (result && result.preview) {
32
+ const o = ui.panel === "output" && ui.output;
33
+ if (!o || o.id !== id || o.pattern.trim() !== result.pattern) return;
34
+ o.preview = result;
35
+ return render();
36
+ }
30
37
  if (!ui.pending || id !== ui.pending || !result) return;
31
38
  ui.busy = false;
32
39
  const ok = result.ok === true;
@@ -41,6 +48,10 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
41
48
  a.cssValue = result.css.value;
42
49
  a.expected = prefill(a, a.matcher);
43
50
  }
51
+ } else if (ui.panel === "output" && ui.output && !ui.output.info) {
52
+ ui.output.info = result;
53
+ if (ok) ui.focus = "outputName";
54
+ if (ok && result.showsSecret) askPreview(ui.output);
44
55
  } else if (ui.panel === "page" && ui.page && !ui.page.info) {
45
56
  ui.page.info = result;
46
57
  if (ok) {
@@ -51,6 +62,9 @@ export const RECORDER_SIDE_JS = String.raw ` // ---- The recorder's side ----
51
62
  closePanel();
52
63
  } else if (ui.panel === "assert" && ui.assert) {
53
64
  ui.assert.error = result.error;
65
+ } else if (ui.panel === "output" && ui.output) {
66
+ if (result.field === "name") ui.output.nameError = result.error;
67
+ else ui.output.error = result.error;
54
68
  } else if (ui.panel === "page" && ui.page) {
55
69
  ui.page.error = result.error;
56
70
  } 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,50 @@ 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
+ // An element that shows a secret reaches the page with it masked: the recorder works out its preview from the real text
86
+ const askPreview = (o) => {
87
+ o.preview = null;
88
+ post({ action: "output-preview", id: o.id, pattern: o.pattern.trim() });
89
+ };
90
+ const outputCapture = (o) => {
91
+ if (!o.info.showsSecret) return captureOf(o.info.outputText, o.pattern);
92
+ if (!o.preview) return { value: "…" };
93
+ return o.preview.ok ? { value: o.preview.value, secret: o.preview.secret } : { error: o.preview.error };
94
+ };
95
+ const outputPanel = () => {
96
+ const o = ui.output;
97
+ const info = o.info;
98
+ const head = h("div", { class: "h" }, "Save as output", info && info.ok && h("code", { title: info.label }, info.label));
99
+ if (!info) return [head, h("div", { class: "msg" }, "…")];
100
+ if (!info.ok) return [head, h("div", { class: "err" }, info.error)];
101
+ const capture = outputCapture(o);
102
+ return [
103
+ head,
104
+ info.showsSecret && h("div", { class: "warn out-secret" }, "This element shows a secret. The saved output may contain it; use a pattern that captures only the non-secret part."),
105
+ h("div", { class: "lbl" }, "Text"),
106
+ h("div", { class: "msg out-text" }, info.showsSecret ? info.outputText : info.text),
107
+ h("div", { class: "lbl" }, "Name"),
108
+ 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" })),
109
+ o.nameError && h("div", { class: "field-error" }, o.nameError),
110
+ h("div", { class: "lbl" }, "Pattern (optional)"),
111
+ 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" })),
112
+ h("div", { class: "lbl" }, "Saves"),
113
+ capture.error ? h("div", { class: "warn out-capture" }, capture.error) : h("code", { class: "out-capture" }, capture.value),
114
+ capture.secret && h("div", { class: "err out-leak" }, "The output will contain a secret: reports mask it, but whatever reads the output gets the real value."),
115
+ o.error && h("div", { class: "err" }, o.error),
116
+ h("div", { class: "foot" }, h("button", { class: "add", "data-action": "output-save", disabled: ui.busy || !o.name.trim() }, "Save")),
117
+ ];
118
+ };
119
+
76
120
  const pagePanel = () => {
77
121
  const p = ui.page;
78
122
  const head = h("div", { class: "h" }, "Page");
@@ -98,13 +142,14 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
98
142
  filter,
99
143
  variables: state.canAddVariables ? state.variables.filter(hit) : [],
100
144
  secrets: state.secrets.filter(hit),
145
+ inputs: (state.inputs || []).filter(hit),
101
146
  };
102
147
  };
103
148
  const insertPanel = () => {
104
149
  const ins = ui.insert;
105
150
  if (!ins.field) return [h("div", { class: "h" }, "Insert"), h("div", { class: "msg" }, "Click into a field first")];
106
151
  const found = matches();
107
- const none = found.filter && found.variables.length === 0 && found.secrets.length === 0;
152
+ const none = found.filter && found.variables.length === 0 && found.secrets.length === 0 && found.inputs.length === 0;
108
153
  const item = (kind, name, value) =>
109
154
  h("button", { class: "it", "data-action": "insert-item", "data-kind": kind, "data-name": name, disabled: ui.busy }, h("code", null, name), value);
110
155
  return [
@@ -120,13 +165,19 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
120
165
  found.secrets.length
121
166
  ? found.secrets.map((s) => item("secret", s.name, s.set ? h("span", { class: "v" }, "••••••") : h("span", { class: "warn" }, "not set")))
122
167
  : h("div", { class: "empty" }, found.filter ? "No secret matches." : "No secrets yet."),
168
+ h("div", { class: "grp" }, "Inputs"),
169
+ found.inputs.length
170
+ ? found.inputs.map((i) => item("input", i.name, h("span", { class: "v", title: i.value }, i.value)))
171
+ : h("div", { class: "empty" }, found.filter ? "No input matches." : "No inputs yet."),
123
172
  ins.error && h("div", { class: "err" }, ins.error),
124
173
  h("button", { class: "act", "data-action": "insert-new", "data-kind": "env" }, "+ Add variable" + (none ? ' "' + variableName(ins.filter) + '"' : "")),
125
174
  h("button", { class: "act", "data-action": "insert-new", "data-kind": "secret" }, "+ Add secret" + (none ? ' "' + snake(ins.filter) + '"' : "")),
175
+ h("button", { class: "act", "data-action": "insert-new", "data-kind": "input" }, "+ Add input" + (none ? ' "' + camel(ins.filter) + '"' : "")),
126
176
  ];
127
177
  };
128
178
 
129
- const newHint = (secret, env, name) => {
179
+ const newHint = (kind, secret, env, name) => {
180
+ if (kind === "input") return "Declared in the test's inputs, with this value as its default.";
130
181
  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
182
  if (!state.secretsIgnored) return h("span", { class: "warn" }, "Add .env." + env + " to .gitignore before committing.");
132
183
  return ["Saved as ", h("code", null, "SAMURAI_SECRET_" + (name.trim() || "<NAME>")), " in ", h("code", null, ".env." + env), " (git-ignored)."];
@@ -134,22 +185,23 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
134
185
  const newPanel = () => {
135
186
  const f = ui.form;
136
187
  const secret = f.kind === "secret";
137
- const blocked = !secret && !state.canAddVariables;
188
+ const blocked = f.kind === "env" && !state.canAddVariables;
138
189
  const env = state.environment;
139
190
  return [
140
- h("div", { class: "h stack" }, f.setValue ? "Set value" : secret ? "New secret" : "New variable", h("small", null, 'For "' + fieldLabel(ui.insert.field) + '"')),
191
+ 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
192
  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")),
193
+ h("button", { class: f.kind === "env" ? "sel" : "", "data-action": "new-kind", "data-kind": "env" }, "Variable"),
194
+ h("button", { class: secret ? "sel" : "", "data-action": "new-kind", "data-kind": "secret" }, "Secret"),
195
+ h("button", { class: f.kind === "input" ? "sel" : "", "data-action": "new-kind", "data-kind": "input" }, "Input")),
144
196
  blocked
145
197
  ? h("div", { class: "msg" }, "This recording has no environment")
146
198
  : [
147
199
  h("div", { class: "lbl" }, "Name"),
148
200
  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
201
  f.nameError && h("div", { class: "field-error" }, f.nameError),
150
- h("div", { class: "lbl" }, "Value in " + env),
202
+ h("div", { class: "lbl" }, f.kind === "input" ? "Default value" : "Value in " + env),
151
203
  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)),
204
+ h("div", { class: "hint" }, newHint(f.kind, secret, env, f.name)),
153
205
  f.error && h("div", { class: "err" }, f.error),
154
206
  ],
155
207
  h("div", { class: "foot plain" },
@@ -181,5 +233,5 @@ export const VIEWS_PANELS_JS = String.raw ` const unavailable = (info, matcher)
181
233
  radio("fullPage", "true", ui.shot.fullPage, "Whole page"),
182
234
  h("div", { class: "foot" }, h("button", { class: "add", "data-action": "screenshot-add", disabled: ui.busy }, "Add")),
183
235
  ];
184
- const PANELS = { assert: assertPanel, page: pagePanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
236
+ const PANELS = { assert: assertPanel, output: outputPanel, page: pagePanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
185
237
  `;
@@ -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"),
@@ -9,22 +9,23 @@
9
9
  * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
10
10
  * - `update(state)`: the recorder's state, after every change and every page load.
11
11
  * - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
12
- * `assert-page`, `insert` and `add-insertable` (an `ActionReply`).
12
+ * `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
13
+ * `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
13
14
  * - `root`: the shadow root, for tests.
14
15
  * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
15
16
  * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
16
- * `assert`, `page`, `insert`, `wait`, `screenshot` (open or close the tool);
17
+ * `assert`, `output`, `page`, `insert`, `wait`, `screenshot` (open or close the tool);
17
18
  * `pick { target }` (the element a picking click hit);
18
- * `matcher { matcher }`, `not`, `assert-add` (Assert panel);
19
+ * `matcher { matcher }`, `not`, `assert-add` (Assert panel); `output-save` (Save as output panel);
19
20
  * `page-matcher { matcher }`, `page-not`, `page-add` (Page panel);
20
21
  * `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
21
22
  * `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
22
23
  * `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
23
- * Form inputs carry `data-field` (`expected`, `attribute`, `cssName`, `countIndex`, `pageExpected`, `filter`,
24
+ * Form inputs carry `data-field` (`expected`, `attribute`, `cssName`, `countIndex`, `outputName`, `outputPattern`, `pageExpected`, `filter`,
24
25
  * `name`, `value`, `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
25
26
  * - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
26
27
  * swaps in that recording's toolbar channel.
27
- * - `state()`, `picking()`: the last state from `update` and whether the Assert picker is on (for tests).
28
+ * - `state()`, `picking()`: the last state from `update` and whether the Assert or Save as output picker is on (for tests).
28
29
  * - `dispose()`: removes the host and every listener the toolbar put on the window and document (the
29
30
  * recording ended while the document stays open).
30
31
  *
@@ -24,22 +24,23 @@ import { ATTACH_JS } from "./source/attach.js";
24
24
  * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
25
25
  * - `update(state)`: the recorder's state, after every change and every page load.
26
26
  * - `reply(id, result)`: the answer to `pick` (a `PickInfo`), to `page-info` (a `PageInfo`) or to `assert`,
27
- * `assert-page`, `insert` and `add-insertable` (an `ActionReply`).
27
+ * `assert-page`, `insert`, `add-insertable` and `save-output` (an `ActionReply`), or to `output-preview` (an
28
+ * `OutputPreview`, which carries `preview: true` and doesn't end a pending action).
28
29
  * - `root`: the shadow root, for tests.
29
30
  * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
30
31
  * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
31
- * `assert`, `page`, `insert`, `wait`, `screenshot` (open or close the tool);
32
+ * `assert`, `output`, `page`, `insert`, `wait`, `screenshot` (open or close the tool);
32
33
  * `pick { target }` (the element a picking click hit);
33
- * `matcher { matcher }`, `not`, `assert-add` (Assert panel);
34
+ * `matcher { matcher }`, `not`, `assert-add` (Assert panel); `output-save` (Save as output panel);
34
35
  * `page-matcher { matcher }`, `page-not`, `page-add` (Page panel);
35
36
  * `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
36
37
  * `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
37
38
  * `wait-add`, `screenshot-add`; `escape` (the Esc key); `dismiss { id }` (a warning's Dismiss button).
38
- * Form inputs carry `data-field` (`expected`, `attribute`, `cssName`, `countIndex`, `pageExpected`, `filter`,
39
+ * Form inputs carry `data-field` (`expected`, `attribute`, `cssName`, `countIndex`, `outputName`, `outputPattern`, `pageExpected`, `filter`,
39
40
  * `name`, `value`, `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
40
41
  * - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
41
42
  * swaps in that recording's toolbar channel.
42
- * - `state()`, `picking()`: the last state from `update` and whether the Assert picker is on (for tests).
43
+ * - `state()`, `picking()`: the last state from `update` and whether the Assert or Save as output picker is on (for tests).
43
44
  * - `dispose()`: removes the host and every listener the toolbar put on the window and document (the
44
45
  * recording ended while the document stays open).
45
46
  *
@@ -1,7 +1,7 @@
1
1
  import type { ElementHandle } from "../../script/element-handle.js";
2
2
  import type { LocatorCall, LocatorSpec } from "../../steps/model.js";
3
3
  import type { Observation } from "../normalise.js";
4
- import type { ActionReply, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
4
+ import type { ActionReply, OutputPreview, PageInfo, PickInfo, ToolbarMessage, ToolbarState } from "./protocol.js";
5
5
  /** What `inspect` found for a picked element: the reply, and the locator behind each Count choice */
6
6
  export interface Inspection {
7
7
  info: PickInfo & {
@@ -14,7 +14,7 @@ export interface Inspection {
14
14
  export interface ControllerDeps {
15
15
  /** Sends the state to every loaded top-level document's toolbar */
16
16
  push(state: ToolbarState): void;
17
- reply(id: number, result: PickInfo | PageInfo | ActionReply): void;
17
+ reply(id: number, result: PickInfo | PageInfo | ActionReply | OutputPreview): void;
18
18
  currentUrl(): Promise<string>;
19
19
  /** The recorded page's URL and title */
20
20
  pageInfo(): Promise<{
@@ -47,6 +47,8 @@ export interface ControllerOptions {
47
47
  /** Name → value; undefined when not set */
48
48
  secrets: Record<string, string | undefined>;
49
49
  secretsIgnored: boolean;
50
+ /** The test's declared inputs: name → default */
51
+ inputs?: Record<string, string>;
50
52
  /** Screenshot steps already in the test, for the default name */
51
53
  screenshots: number;
52
54
  }
@@ -56,20 +58,25 @@ export type Message = ToolbarMessage & {
56
58
  export type Of<A extends Message["action"]> = Extract<Message, {
57
59
  action: A;
58
60
  }>;
59
- /** The element picked for an Assert, and the locator behind each of its Count choices */
61
+ /** The element picked for an Assert or Save as output, the locator behind each of its Count choices, and its text */
60
62
  export interface PickState {
61
63
  id: number;
62
64
  locator: LocatorSpec;
63
65
  counts: LocatorCall[];
66
+ /** Its text as `outputs.save` reads it, when it was picked, secrets included: it never goes back to the page */
67
+ text: string;
64
68
  }
65
- /** What the action handlers (assert, insert, add a variable or secret) use of the controller */
69
+ /** The messages that expect a reply, and that a double click sends twice */
70
+ export type Replying = Of<"assert" | "assert-page" | "insert" | "add-insertable" | "save-output">;
71
+ /** What the action handlers (assert, insert, add a variable, secret or input, save as output) use of the controller */
66
72
  export interface ActionContext {
67
73
  readonly deps: ControllerDeps;
68
74
  readonly options: ControllerOptions;
69
75
  readonly variables: Map<string, string>;
70
76
  readonly secrets: Map<string, string | undefined>;
77
+ readonly inputs: Map<string, string>;
71
78
  isPaused(): boolean;
72
- /** The picked element an `assert` with the same id refers to */
79
+ /** The picked element an `assert` or `save-output` with the same id refers to */
73
80
  pick(): PickState | undefined;
74
81
  setPick(pick: PickState | undefined): void;
75
82
  showToast(text: string): void;
@@ -24,6 +24,11 @@ export interface RunnerOptions {
24
24
  * @default the config's `retries`, else 0
25
25
  */
26
26
  retries?: number | string;
27
+ /**
28
+ * Values for the inputs the tests declare, by name: every test of the run reads the names it declares from
29
+ * here, and its own default for a name missing here
30
+ */
31
+ inputs?: Record<string, string>;
27
32
  /** Run the browser without a window. @default false */
28
33
  headless?: boolean;
29
34
  /** Remote debugging port of the browser. @default DEFAULT_PORT (9223) */
@@ -16,9 +16,10 @@ export interface PlannedTest {
16
16
  }
17
17
  /**
18
18
  * Lists the run: every selected test in the first browser, then every selected test in the next one. The
19
- * position in the list is the item's `index`. `serialKey` says which group makes a test run alone.
19
+ * position in the list is the item's `index`. `serialKey` says which group makes a test run alone. `inputs`, the
20
+ * run's input values, go with every item.
20
21
  */
21
- export declare function planItems(selected: RegisteredTestCase[], browsers: SupportedBrowser[], serialKey: (test: RegisteredTestCase) => string | undefined): {
22
+ export declare function planItems(selected: RegisteredTestCase[], browsers: SupportedBrowser[], serialKey: (test: RegisteredTestCase) => string | undefined, inputs?: Record<string, string>): {
22
23
  items: WorkItem[];
23
24
  planned: PlannedTest[];
24
25
  };
@@ -2,9 +2,10 @@ import { idOf } from "./select.js";
2
2
  import { staticSkip } from "./skip.js";
3
3
  /**
4
4
  * Lists the run: every selected test in the first browser, then every selected test in the next one. The
5
- * position in the list is the item's `index`. `serialKey` says which group makes a test run alone.
5
+ * position in the list is the item's `index`. `serialKey` says which group makes a test run alone. `inputs`, the
6
+ * run's input values, go with every item.
6
7
  */
7
- export function planItems(selected, browsers, serialKey) {
8
+ export function planItems(selected, browsers, serialKey, inputs) {
8
9
  const planned = browsers.flatMap((browser) => selected.map((test) => ({ test, browser })));
9
10
  // A test that does not run (skip, fixme) is in the plan but not in the queue; `index` is its place in the plan
10
11
  const items = planned.flatMap(({ test, browser }, index) => {
@@ -18,6 +19,7 @@ export function planItems(selected, browsers, serialKey) {
18
19
  testName: test.name,
19
20
  browser,
20
21
  ...(key !== undefined && { serialKey: key }),
22
+ ...(inputs !== undefined && { inputs }),
21
23
  },
22
24
  ];
23
25
  });
@@ -1,14 +1,18 @@
1
- import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase, TestDetails } from "../types/test.js";
1
+ import type { AllHook, EachHook, RegisteredTestCase, TestCaseBase, TestDetails, TestOptions } from "../types/test.js";
2
2
  type Body = TestCaseBase["function"];
3
3
  /** `test(title, fn)`, `test.skip(title, fn)`, `test.skip(condition, reason)` and the other forms */
4
4
  export interface TestFunction {
5
5
  (title: string, fn: Body): void;
6
+ (title: string, options: TestOptions, fn: Body): void;
6
7
  /** Registers a test that is not run */
7
8
  skip: SkipFunction;
8
9
  /** Registers a test that is not run because it is known to be broken */
9
10
  fixme: SkipFunction;
10
11
  /** Runs only this test (and the other `only` ones) */
11
- only(title: string, fn: Body): void;
12
+ only: {
13
+ (title: string, fn: Body): void;
14
+ (title: string, options: TestOptions, fn: Body): void;
15
+ };
12
16
  }
13
17
  /**
14
18
  * With a title and a body it registers a skipped test, `{ reason }` saying why; with a condition it skips the
@@ -16,7 +20,7 @@ export interface TestFunction {
16
20
  */
17
21
  interface SkipFunction {
18
22
  (title: string, fn: Body): void;
19
- (title: string, details: TestDetails, fn: Body): void;
23
+ (title: string, options: TestOptions, fn: Body): void;
20
24
  (condition: boolean, reason?: string): void;
21
25
  }
22
26
  /** `describe.skip` and `describe.fixme`: every test inside gets the modifier, and `{ reason }` says why */
@@ -1,6 +1,7 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import logger from "../logger/index.js";
3
3
  import { SkipSignal } from "./skip.js";
4
+ import { validateTestIO } from "./test-io.js";
4
5
  import { addHook, currentSuite, enterSuite, leaveSuite, modifierOf, resetSuites, } from "./suite.js";
5
6
  const registered = [];
6
7
  /** Runs `fn` as the `describe` block `title`, under `modifier` when there is one. `fn` runs immediately and must be synchronous. */
@@ -17,7 +18,8 @@ function openDescribe(file, title, fn, modifier, reason) {
17
18
  leaveSuite();
18
19
  }
19
20
  }
20
- function add(file, title, fn, modifier, reason) {
21
+ function add(file, title, fn, modifier, extras = {}) {
22
+ const { reason, inputs, outputs } = extras;
21
23
  const suite = currentSuite(file);
22
24
  const titlePath = [...titlesOf(suite), title];
23
25
  const name = titlePath.join(" > ");
@@ -28,6 +30,8 @@ function add(file, title, fn, modifier, reason) {
28
30
  file,
29
31
  suite,
30
32
  ...modifierOf({ modifier, ...(reason !== undefined && { reason }) }, suite),
33
+ ...(inputs && { inputs }),
34
+ ...(outputs && { outputs }),
31
35
  });
32
36
  logger.debug("Registered test %s", name);
33
37
  }
@@ -39,23 +43,37 @@ function titlesOf(suite) {
39
43
  titles.unshift(s.title);
40
44
  return titles;
41
45
  }
42
- /** The body and `{ reason }` of `(title, fn)` and `(title, details, fn)` */
43
- function bodyAndReason(call, title, second, third) {
44
- const [details, fn] = typeof second === "function"
46
+ /** The body and options of `(title, fn)` and `(title, options, fn)`; `describe` only reads `reason` from them */
47
+ function bodyAndOptions(call, title, second, third) {
48
+ const [options, fn] = typeof second === "function"
45
49
  ? [undefined, second]
46
50
  : [second, third];
47
51
  if (typeof fn !== "function")
48
52
  throw new TypeError(`${call}(${JSON.stringify(title)}) needs a function as its last argument`);
49
- const reason = details?.reason;
50
- return { fn: fn, ...(reason !== undefined && { reason }) };
53
+ const reason = options?.reason;
54
+ return {
55
+ fn: fn,
56
+ ...(reason !== undefined && { reason }),
57
+ ...(options && call.startsWith("test") && validateTestIO(call, options)),
58
+ };
59
+ }
60
+ /** `test()` and `test.only()`: a reason only makes sense with skip and fixme */
61
+ function registering(call, modifier) {
62
+ return ((title, second, third) => {
63
+ const { fn, reason, ...io } = bodyAndOptions(call, title, second, third);
64
+ if (reason !== undefined)
65
+ throw new Error(`${call}(): reason only applies to skip and fixme`);
66
+ // Frames: Error, callerFile, this function, the spec
67
+ add(callerFile(), title, fn, modifier, io);
68
+ });
51
69
  }
52
70
  /** `test.skip` and `test.fixme`: a title and a body register a test with the modifier; a condition skips the running test */
53
71
  function skipping(annotation) {
54
72
  return ((first, second, third) => {
55
73
  if (typeof first === "string") {
56
- const { fn, reason } = bodyAndReason(`test.${annotation}`, first, second, third);
74
+ const { fn, ...extras } = bodyAndOptions(`test.${annotation}`, first, second, third);
57
75
  // Frames: Error, callerFile, this function, the spec
58
- add(callerFile(), first, fn, annotation, reason);
76
+ add(callerFile(), first, fn, annotation, extras);
59
77
  return;
60
78
  }
61
79
  if (first)
@@ -65,14 +83,14 @@ function skipping(annotation) {
65
83
  /** `describe.skip` and `describe.fixme`, with or without `{ reason }` */
66
84
  function describeSkipping(annotation) {
67
85
  return ((title, second, third) => {
68
- const { fn, reason } = bodyAndReason(`describe.${annotation}`, title, second, third);
86
+ const { fn, reason } = bodyAndOptions(`describe.${annotation}`, title, second, third);
69
87
  openDescribe(callerFile(), title, fn, annotation, reason);
70
88
  });
71
89
  }
72
- export const test = Object.assign((title, fn) => add(callerFile(), title, fn), {
90
+ export const test = Object.assign(registering("test"), {
73
91
  skip: skipping("skip"),
74
92
  fixme: skipping("fixme"),
75
- only: (title, fn) => add(callerFile(), title, fn, "only"),
93
+ only: registering("test.only", "only"),
76
94
  });
77
95
  export const describe = Object.assign((title, fn) => openDescribe(callerFile(), title, fn), {
78
96
  skip: describeSkipping("skip"),