@itmetsam/samurai-framework 0.6.0 → 0.7.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 (51) hide show
  1. package/README.md +2 -2
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/browser/evidence.d.ts +19 -0
  4. package/dist/browser/evidence.js +35 -0
  5. package/dist/browser/page.d.ts +6 -0
  6. package/dist/browser/page.js +20 -3
  7. package/dist/browser/screenshot.d.ts +2 -0
  8. package/dist/browser/screenshot.js +5 -2
  9. package/dist/cli/browsers.d.ts +1 -1
  10. package/dist/cli/browsers.js +2 -4
  11. package/dist/cli/main.js +15 -1
  12. package/dist/project/config-edit.d.ts +16 -0
  13. package/dist/project/config-edit.js +218 -0
  14. package/dist/project/index.d.ts +3 -0
  15. package/dist/project/index.js +3 -0
  16. package/dist/project/secrets-file.d.ts +8 -0
  17. package/dist/project/secrets-file.js +69 -0
  18. package/dist/project/variables.d.ts +6 -0
  19. package/dist/project/variables.js +17 -0
  20. package/dist/recorder/candidates.d.ts +7 -0
  21. package/dist/recorder/candidates.js +118 -0
  22. package/dist/recorder/capture.d.ts +8 -1
  23. package/dist/recorder/capture.js +25 -116
  24. package/dist/recorder/index.d.ts +1 -1
  25. package/dist/recorder/normalise.d.ts +28 -1
  26. package/dist/recorder/normalise.js +40 -1
  27. package/dist/recorder/recorder.d.ts +54 -0
  28. package/dist/recorder/recorder.js +195 -10
  29. package/dist/recorder/session.d.ts +15 -0
  30. package/dist/recorder/session.js +57 -7
  31. package/dist/recorder/toolbar/controller.d.ts +136 -0
  32. package/dist/recorder/toolbar/controller.js +471 -0
  33. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  34. package/dist/recorder/toolbar/inspect.js +94 -0
  35. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  36. package/dist/recorder/toolbar/protocol.js +4 -0
  37. package/dist/recorder/toolbar/source.d.ts +32 -0
  38. package/dist/recorder/toolbar/source.js +856 -0
  39. package/dist/recorder/toolbar/styles.d.ts +6 -0
  40. package/dist/recorder/toolbar/styles.js +95 -0
  41. package/dist/runner/reporter.d.ts +5 -1
  42. package/dist/runner/reporter.js +4 -3
  43. package/dist/runner/test-runner.d.ts +9 -0
  44. package/dist/runner/test-runner.js +39 -4
  45. package/dist/steps/emit.js +4 -0
  46. package/dist/steps/model.d.ts +11 -0
  47. package/dist/steps/parse.js +22 -3
  48. package/dist/transport/bidi-connection.js +32 -1
  49. package/dist/types/bidi-modules/script.d.ts +20 -3
  50. package/dist/types/test.d.ts +4 -0
  51. package/package.json +5 -1
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Declarations that turn an element into locator candidates (test id, role and name, label, text, css and
3
+ * an absolute xpath), plus `inToolbar(event)`, which is true for events from the recorder toolbar. A string
4
+ * of plain declarations, interpolated into the capture script (page realm) and the toolbar script
5
+ * (sandbox realm), so no transpiler helper can end up in it.
6
+ */
7
+ export const CANDIDATES_JS = String.raw `
8
+ const norm = (text) => String(text == null ? "" : text).replace(/\s+/g, " ").trim();
9
+ const textOf = (node) => norm(node.innerText != null ? node.innerText : node.textContent);
10
+ const NO_ROLE_TYPES = ["hidden", "password", "file", "color", "date", "datetime-local", "month", "time", "week"];
11
+ const TEXT_TYPES = ["", "text", "email", "search", "tel", "url", "password", "number"];
12
+
13
+ const roleOf = (el) => {
14
+ const explicit = norm(el.getAttribute("role")).split(" ")[0];
15
+ if (explicit) return explicit;
16
+ const tag = el.tagName.toLowerCase();
17
+ if (tag === "a") return el.hasAttribute("href") ? "link" : undefined;
18
+ if (tag === "button" || tag === "summary") return "button";
19
+ if (tag === "select") return el.multiple || el.size > 1 ? "listbox" : "combobox";
20
+ if (tag === "textarea") return "textbox";
21
+ if (/^h[1-6]$/.test(tag)) return "heading";
22
+ if (tag === "img") return "img";
23
+ if (tag === "input") {
24
+ const type = (el.getAttribute("type") || "text").toLowerCase();
25
+ if (NO_ROLE_TYPES.indexOf(type) >= 0) return undefined;
26
+ if (type === "checkbox" || type === "radio") return type;
27
+ if (type === "button" || type === "submit" || type === "reset" || type === "image") return "button";
28
+ if (type === "range") return "slider";
29
+ if (type === "number") return "spinbutton";
30
+ if (type === "search") return "searchbox";
31
+ return "textbox";
32
+ }
33
+ return undefined;
34
+ };
35
+
36
+ const labelTexts = (el) => {
37
+ const texts = [];
38
+ if (el.labels) for (const label of el.labels) texts.push(textOf(label));
39
+ const by = el.getAttribute("aria-labelledby");
40
+ if (by) {
41
+ for (const id of by.split(/\s+/)) {
42
+ const named = id && document.getElementById(id);
43
+ if (named) texts.push(textOf(named));
44
+ }
45
+ }
46
+ const own = el.getAttribute("aria-label");
47
+ if (own) texts.push(norm(own));
48
+ return texts.filter(Boolean);
49
+ };
50
+
51
+ const nameOf = (el, role) => {
52
+ const labelled = labelTexts(el)[0];
53
+ if (labelled) return labelled;
54
+ const tag = el.tagName.toLowerCase();
55
+ if (tag === "input") {
56
+ const type = (el.getAttribute("type") || "").toLowerCase();
57
+ return type === "submit" || type === "button" || type === "reset" ? norm(el.value) : "";
58
+ }
59
+ if (tag === "img") return norm(el.getAttribute("alt"));
60
+ if (role === "textbox" || role === "combobox" || role === "listbox") return "";
61
+ return textOf(el) || norm(el.getAttribute("title"));
62
+ };
63
+
64
+ const plain = (value) => value.indexOf('"') < 0 && value.indexOf("\\") < 0;
65
+
66
+ const xpathOf = (el) => {
67
+ const parts = [];
68
+ for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
69
+ let index = 1;
70
+ for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
71
+ if (sibling.tagName === node.tagName) index++;
72
+ }
73
+ parts.unshift(node.tagName.toLowerCase() + "[" + index + "]");
74
+ }
75
+ return "/" + parts.join("/");
76
+ };
77
+
78
+ const candidatesOf = (el) => {
79
+ const found = [];
80
+ const tag = el.tagName.toLowerCase();
81
+ const testId = el.getAttribute("data-testid");
82
+ if (testId) found.push({ method: "getByTestId", testId });
83
+ const role = roleOf(el);
84
+ if (role) {
85
+ const name = nameOf(el, role);
86
+ if (name && name.length <= 80) found.push({ method: "getByRole", role, name });
87
+ }
88
+ const label = labelTexts(el)[0];
89
+ if (label && label.length <= 80 && el.labels !== undefined) found.push({ method: "getByLabel", text: label });
90
+ const text = textOf(el);
91
+ if (text && text.length <= 60 && tag !== "input" && tag !== "textarea" && tag !== "select") {
92
+ found.push({ method: "getByText", text });
93
+ }
94
+ if (el.id && /^[A-Za-z][\w-]*$/.test(el.id) && !/\d{3,}/.test(el.id)) found.push({ method: "getByCss", css: "#" + el.id });
95
+ const name = el.getAttribute("name");
96
+ if (name && plain(name)) found.push({ method: "getByCss", css: tag + '[name="' + name + '"]' });
97
+ const placeholder = el.getAttribute("placeholder");
98
+ if (placeholder && plain(placeholder)) found.push({ method: "getByCss", css: tag + '[placeholder="' + placeholder + '"]' });
99
+ found.push({ method: "locator", xpath: xpathOf(el) });
100
+ return found;
101
+ };
102
+
103
+ const isTextEntry = (el) => {
104
+ const tag = el.tagName.toLowerCase();
105
+ if (tag === "textarea") return true;
106
+ if (tag === "input") return TEXT_TYPES.indexOf((el.getAttribute("type") || "").toLowerCase()) >= 0;
107
+ return Boolean(el.isContentEditable);
108
+ };
109
+
110
+ const INTERACTIVE = "a[href],button,input,select,textarea,label,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch]";
111
+ const interactiveAt = (target) => {
112
+ const found = target.closest(INTERACTIVE) || target;
113
+ return found.tagName === "LABEL" && found.control ? found.control : found;
114
+ };
115
+ const TOOLBAR_TAG = "SAMURAI-RECORDER-TOOLBAR";
116
+ const inToolbar = (event) =>
117
+ event.composedPath().some((node) => node && node.tagName === TOOLBAR_TAG);
118
+ `;
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Function declaration installed as a preload script, called as `(send)` where `send` is the recorder's
3
- * channel. Reports what the tester does in the top-level document:
3
+ * channel. `send` stays in this closure and is never put on the page's window, so a page script cannot
4
+ * report anything. A second run in a document that was recorded before (a new recording) adds its own
5
+ * listeners; those of the earlier run go quiet, as their run is no longer the current one.
6
+ * Reports what the tester does in the top-level document:
4
7
  *
5
8
  * - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
6
9
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
@@ -18,3 +21,7 @@
18
21
  export declare const CAPTURE_SOURCE: string;
19
22
  /** Run in every loaded document to stop its capture script reporting and swallowing Alt+clicks */
20
23
  export declare const CAPTURE_OFF = "() => { window[Symbol.for(\"samurai.recorder.off\")] = true; }";
24
+ /** Run in every loaded document to pause its capture script (the toolbar's Pause, and while it types a value) */
25
+ export declare const CAPTURE_MUTE = "() => { window[Symbol.for(\"samurai.recorder.muted\")] = true; }";
26
+ /** Run in every loaded document to resume what `CAPTURE_MUTE` paused */
27
+ export declare const CAPTURE_UNMUTE = "() => { window[Symbol.for(\"samurai.recorder.muted\")] = false; }";
@@ -1,8 +1,16 @@
1
+ import { CANDIDATES_JS } from "./candidates.js";
1
2
  /** Name of the window flag that tells the capture script to stop reporting (see `CAPTURE_OFF`) */
2
3
  const OFF_KEY = "samurai.recorder.off";
4
+ /** Name of the window flag that pauses the capture script (see `CAPTURE_MUTE`) */
5
+ const MUTED_KEY = "samurai.recorder.muted";
6
+ /** Name of the window counter that tells a document's capture scripts which run is the current one */
7
+ const RUN_KEY = "samurai.recorder.run";
3
8
  /**
4
9
  * Function declaration installed as a preload script, called as `(send)` where `send` is the recorder's
5
- * channel. Reports what the tester does in the top-level document:
10
+ * channel. `send` stays in this closure and is never put on the page's window, so a page script cannot
11
+ * report anything. A second run in a document that was recorded before (a new recording) adds its own
12
+ * listeners; those of the earlier run go quiet, as their run is no longer the current one.
13
+ * Reports what the tester does in the top-level document:
6
14
  *
7
15
  * - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
8
16
  * element (a label click reports its control; the click the browser forwards to the control is dropped).
@@ -19,125 +27,17 @@ const OFF_KEY = "samurai.recorder.off";
19
27
  */
20
28
  export const CAPTURE_SOURCE = String.raw `(send) => {
21
29
  const OFF = Symbol.for("${OFF_KEY}");
22
- const ON = Symbol.for("samurai.recorder.on");
23
- const SEND = Symbol.for("samurai.recorder.send");
30
+ const RUN = Symbol.for("${RUN_KEY}");
24
31
  if (window !== window.top) return;
25
- // A document that was recorded before gets the new channel; its listeners are already in place
26
32
  window[OFF] = false;
27
- window[SEND] = send;
28
- if (window[ON]) return;
29
- window[ON] = true;
30
- const report = (message) => window[SEND](message);
33
+ // This run's number; the listeners of an earlier run in this document stop reporting
34
+ const run = (window[RUN] = (typeof window[RUN] === "number" ? window[RUN] : 0) + 1);
35
+ const report = (message) => send(message);
31
36
 
32
- const norm = (text) => String(text == null ? "" : text).replace(/\s+/g, " ").trim();
33
- const textOf = (node) => norm(node.innerText != null ? node.innerText : node.textContent);
34
- const NO_ROLE_TYPES = ["hidden", "password", "file", "color", "date", "datetime-local", "month", "time", "week"];
35
- const TEXT_TYPES = ["", "text", "email", "search", "tel", "url", "password", "number"];
37
+ ${CANDIDATES_JS}
36
38
 
37
- const roleOf = (el) => {
38
- const explicit = norm(el.getAttribute("role")).split(" ")[0];
39
- if (explicit) return explicit;
40
- const tag = el.tagName.toLowerCase();
41
- if (tag === "a") return el.hasAttribute("href") ? "link" : undefined;
42
- if (tag === "button" || tag === "summary") return "button";
43
- if (tag === "select") return el.multiple || el.size > 1 ? "listbox" : "combobox";
44
- if (tag === "textarea") return "textbox";
45
- if (/^h[1-6]$/.test(tag)) return "heading";
46
- if (tag === "img") return "img";
47
- if (tag === "input") {
48
- const type = (el.getAttribute("type") || "text").toLowerCase();
49
- if (NO_ROLE_TYPES.indexOf(type) >= 0) return undefined;
50
- if (type === "checkbox" || type === "radio") return type;
51
- if (type === "button" || type === "submit" || type === "reset" || type === "image") return "button";
52
- if (type === "range") return "slider";
53
- if (type === "number") return "spinbutton";
54
- if (type === "search") return "searchbox";
55
- return "textbox";
56
- }
57
- return undefined;
58
- };
59
-
60
- const labelTexts = (el) => {
61
- const texts = [];
62
- if (el.labels) for (const label of el.labels) texts.push(textOf(label));
63
- const by = el.getAttribute("aria-labelledby");
64
- if (by) {
65
- for (const id of by.split(/\s+/)) {
66
- const named = id && document.getElementById(id);
67
- if (named) texts.push(textOf(named));
68
- }
69
- }
70
- const own = el.getAttribute("aria-label");
71
- if (own) texts.push(norm(own));
72
- return texts.filter(Boolean);
73
- };
74
-
75
- const nameOf = (el, role) => {
76
- const labelled = labelTexts(el)[0];
77
- if (labelled) return labelled;
78
- const tag = el.tagName.toLowerCase();
79
- if (tag === "input") {
80
- const type = (el.getAttribute("type") || "").toLowerCase();
81
- return type === "submit" || type === "button" || type === "reset" ? norm(el.value) : "";
82
- }
83
- if (tag === "img") return norm(el.getAttribute("alt"));
84
- if (role === "textbox" || role === "combobox" || role === "listbox") return "";
85
- return textOf(el) || norm(el.getAttribute("title"));
86
- };
87
-
88
- const plain = (value) => value.indexOf('"') < 0 && value.indexOf("\\") < 0;
89
-
90
- const xpathOf = (el) => {
91
- const parts = [];
92
- for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
93
- let index = 1;
94
- for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
95
- if (sibling.tagName === node.tagName) index++;
96
- }
97
- parts.unshift(node.tagName.toLowerCase() + "[" + index + "]");
98
- }
99
- return "/" + parts.join("/");
100
- };
101
-
102
- const candidatesOf = (el) => {
103
- const found = [];
104
- const tag = el.tagName.toLowerCase();
105
- const testId = el.getAttribute("data-testid");
106
- if (testId) found.push({ method: "getByTestId", testId });
107
- const role = roleOf(el);
108
- if (role) {
109
- const name = nameOf(el, role);
110
- if (name && name.length <= 80) found.push({ method: "getByRole", role, name });
111
- }
112
- const label = labelTexts(el)[0];
113
- if (label && label.length <= 80 && el.labels !== undefined) found.push({ method: "getByLabel", text: label });
114
- const text = textOf(el);
115
- if (text && text.length <= 60 && tag !== "input" && tag !== "textarea" && tag !== "select") {
116
- found.push({ method: "getByText", text });
117
- }
118
- if (el.id && /^[A-Za-z][\w-]*$/.test(el.id) && !/\d{3,}/.test(el.id)) found.push({ method: "getByCss", css: "#" + el.id });
119
- const name = el.getAttribute("name");
120
- if (name && plain(name)) found.push({ method: "getByCss", css: tag + '[name="' + name + '"]' });
121
- const placeholder = el.getAttribute("placeholder");
122
- if (placeholder && plain(placeholder)) found.push({ method: "getByCss", css: tag + '[placeholder="' + placeholder + '"]' });
123
- found.push({ method: "locator", xpath: xpathOf(el) });
124
- return found;
125
- };
126
-
127
- const isTextEntry = (el) => {
128
- const tag = el.tagName.toLowerCase();
129
- if (tag === "textarea") return true;
130
- if (tag === "input") return TEXT_TYPES.indexOf((el.getAttribute("type") || "").toLowerCase()) >= 0;
131
- return Boolean(el.isContentEditable);
132
- };
133
-
134
- const INTERACTIVE = "a[href],button,input,select,textarea,label,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch]";
135
- const interactiveAt = (target) => {
136
- const found = target.closest(INTERACTIVE) || target;
137
- return found.tagName === "LABEL" && found.control ? found.control : found;
138
- };
139
-
140
- const off = () => window[OFF] === true;
39
+ const MUTED = Symbol.for("${MUTED_KEY}");
40
+ const off = () => window[RUN] !== run || window[OFF] === true || window[MUTED] === true;
141
41
  let lastClick = { el: null, at: 0 };
142
42
  let outlined = null;
143
43
  let outlinedStyle = "";
@@ -147,6 +47,7 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
147
47
  };
148
48
 
149
49
  document.addEventListener("click", (event) => {
50
+ if (inToolbar(event)) return;
150
51
  if (off() || !event.isTrusted || !(event.target instanceof Element)) return;
151
52
  if (event.altKey) {
152
53
  event.preventDefault();
@@ -173,6 +74,7 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
173
74
  }, true);
174
75
 
175
76
  document.addEventListener("input", (event) => {
77
+ if (inToolbar(event)) return;
176
78
  const el = event.target;
177
79
  if (off() || !event.isTrusted || !(el instanceof Element) || !isTextEntry(el) || el.isContentEditable) return;
178
80
  const secret = el.tagName === "INPUT" && (el.getAttribute("type") || "").toLowerCase() === "password";
@@ -191,6 +93,7 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
191
93
  // Enter on these is a click, which is recorded as one
192
94
  const ACTIVATES = "a[href],button,summary,select,input[type=button],input[type=submit],input[type=reset],input[type=checkbox],input[type=radio]";
193
95
  document.addEventListener("keydown", (event) => {
96
+ if (inToolbar(event)) return;
194
97
  if (off() || !event.isTrusted || event.isComposing || event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
195
98
  if (RECORDED_KEYS.indexOf(event.key) < 0 || !(event.target instanceof Element)) return;
196
99
  const el = event.target;
@@ -199,6 +102,8 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
199
102
  }, true);
200
103
 
201
104
  document.addEventListener("mousemove", (event) => {
105
+ // Alt+click on the toolbar asserts nothing, so nothing there (or behind it) is outlined
106
+ if (inToolbar(event)) return unhighlight();
202
107
  if (off() || !event.altKey || !(event.target instanceof Element) || event.target === outlined) return;
203
108
  unhighlight();
204
109
  outlined = event.target;
@@ -210,3 +115,7 @@ export const CAPTURE_SOURCE = String.raw `(send) => {
210
115
  }`;
211
116
  /** Run in every loaded document to stop its capture script reporting and swallowing Alt+clicks */
212
117
  export const CAPTURE_OFF = `() => { window[Symbol.for("${OFF_KEY}")] = true; }`;
118
+ /** Run in every loaded document to pause its capture script (the toolbar's Pause, and while it types a value) */
119
+ export const CAPTURE_MUTE = `() => { window[Symbol.for("${MUTED_KEY}")] = true; }`;
120
+ /** Run in every loaded document to resume what `CAPTURE_MUTE` paused */
121
+ export const CAPTURE_UNMUTE = `() => { window[Symbol.for("${MUTED_KEY}")] = false; }`;
@@ -1,3 +1,3 @@
1
- export { Recorder, type RecorderOptions, type RecorderEvent, } from "./recorder.js";
1
+ export { Recorder, type RecorderOptions, type RecorderEvent, type ToolbarOptions, } from "./recorder.js";
2
2
  export { applyRecorderEvent } from "./apply.js";
3
3
  export { recordSpec, pickTest, type RecordSpecOptions, type RecordResult, } from "./session.js";
@@ -1,4 +1,4 @@
1
- import type { LocatorSpec, Step } from "../steps/model.js";
1
+ import type { Expectation, LocatorSpec, Step } from "../steps/model.js";
2
2
  /** What the recorder does to the step list: a new step at `index`, or a step rewritten in place */
3
3
  export type RecorderEvent = {
4
4
  op: "insert";
@@ -33,6 +33,30 @@ export type Observation = {
33
33
  locator: LocatorSpec;
34
34
  text: string;
35
35
  value?: string;
36
+ } | {
37
+ type: "expect";
38
+ locator: LocatorSpec;
39
+ not: boolean;
40
+ expectation: Expectation;
41
+ } | {
42
+ type: "insert";
43
+ locator: LocatorSpec;
44
+ value: {
45
+ kind: "env" | "secret";
46
+ name: string;
47
+ };
48
+ } | {
49
+ type: "wait-idle";
50
+ } | {
51
+ type: "wait-time";
52
+ ms: number;
53
+ } | {
54
+ type: "screenshot";
55
+ name: string;
56
+ fullPage: boolean;
57
+ } | {
58
+ type: "goto";
59
+ url: string;
36
60
  };
37
61
  export interface NormaliserOptions {
38
62
  /** Index the first recorded step gets */
@@ -44,6 +68,7 @@ export interface NormaliserOptions {
44
68
  }
45
69
  /** The name a secret gets in `secrets.<NAME>`: the field's name or id, upper snake case */
46
70
  export declare function secretNameFor(hint: string | undefined): string;
71
+ export declare function expectationFor(text: string, value: string | undefined): Expectation;
47
72
  /**
48
73
  * Turns what the tester did into steps:
49
74
  * - typing into a field becomes one `fill`, rewritten as the text grows (a password becomes a secret reference);
@@ -65,6 +90,8 @@ export declare class Normaliser {
65
90
  private insert;
66
91
  /** The path (and query) of `url` when it is inside the base URL, else `url` */
67
92
  relative(url: string): string;
93
+ /** Steps the toolbar asks for directly; undefined for page observations */
94
+ private toolbarStep;
68
95
  observe(observation: Observation, at: number): RecorderEvent[];
69
96
  /** A top-level navigation started */
70
97
  navigated(url: string, at: number): RecorderEvent[];
@@ -8,7 +8,7 @@ export function secretNameFor(hint) {
8
8
  .toUpperCase();
9
9
  return name === "" || /^\d/.test(name) ? "PASSWORD" : name;
10
10
  }
11
- function expectationFor(text, value) {
11
+ export function expectationFor(text, value) {
12
12
  if (value !== undefined)
13
13
  return { matcher: "toHaveValue", expected: value };
14
14
  if (text === "")
@@ -52,7 +52,44 @@ export class Normaliser {
52
52
  }
53
53
  return url;
54
54
  }
55
+ /** Steps the toolbar asks for directly; undefined for page observations */
56
+ toolbarStep(observation) {
57
+ switch (observation.type) {
58
+ case "expect":
59
+ return {
60
+ kind: "expect",
61
+ locator: observation.locator,
62
+ not: observation.not,
63
+ expectation: observation.expectation,
64
+ };
65
+ case "insert":
66
+ return {
67
+ kind: "fill",
68
+ locator: observation.locator,
69
+ value: observation.value,
70
+ };
71
+ case "wait-idle":
72
+ return { kind: "waitForNetworkIdle" };
73
+ case "wait-time":
74
+ return { kind: "waitForTimeout", ms: observation.ms };
75
+ case "screenshot":
76
+ return {
77
+ kind: "screenshot",
78
+ name: observation.name,
79
+ fullPage: observation.fullPage,
80
+ };
81
+ case "goto":
82
+ return { kind: "goto", url: this.relative(observation.url) };
83
+ default:
84
+ return undefined;
85
+ }
86
+ }
55
87
  observe(observation, at) {
88
+ const fromToolbar = this.toolbarStep(observation);
89
+ if (fromToolbar) {
90
+ this.lastFill = undefined;
91
+ return [this.insert(fromToolbar)];
92
+ }
56
93
  if (observation.type === "click") {
57
94
  if (observation.textEntry)
58
95
  return []; // focusing a field: the fill that follows needs no click
@@ -83,6 +120,8 @@ export class Normaliser {
83
120
  }),
84
121
  ];
85
122
  }
123
+ if (observation.type !== "input")
124
+ return [];
86
125
  this.lastAction = at;
87
126
  const step = {
88
127
  kind: "fill",
@@ -1,7 +1,26 @@
1
1
  import type Page from "../browser/page.js";
2
2
  import type { BiDiConnector } from "../transport/bidi-connection.js";
3
3
  import { type RecorderEvent } from "./normalise.js";
4
+ import { type ControllerDeps, type ControllerOptions } from "./toolbar/controller.js";
4
5
  export type { RecorderEvent } from "./normalise.js";
6
+ /** The toolbar's settings: the recording's environment, and where its variables and secrets are written */
7
+ export type ToolbarOptions = ControllerOptions & {
8
+ /** The project folder: `samurai.config.ts` and `.env.<environment>` */
9
+ projectDir: string;
10
+ /**
11
+ * Called when the recording is paused or resumed, or the toolbar fails (or comes back) on a page.
12
+ * There is no initial call: until the first one the recording is `{ state: "recording", toolbar: true }`
13
+ */
14
+ onStatus?: ControllerDeps["onStatus"];
15
+ /** A secret was saved to `.env.<environment>` from the panel (before its value is typed) */
16
+ onSecretAdded?: (name: string) => void;
17
+ /**
18
+ * The toolbar's Stop; the caller then stops the recording (`recorder.stop()`), as `recordSpec` does.
19
+ * Without it, Stop runs `recorder.stop()` itself: the toolbar goes away and nothing more is recorded, but
20
+ * nothing tells the `page.record()` caller, so pass `onStop` to learn of it.
21
+ */
22
+ onStop?: () => void;
23
+ };
5
24
  export interface RecorderOptions {
6
25
  /** Called for every step the tester produces, in order. Steps get indexes from `at` on */
7
26
  onEvent: (event: RecorderEvent) => void;
@@ -15,6 +34,8 @@ export interface RecorderOptions {
15
34
  initialGoto?: boolean;
16
35
  /** URLs inside it are written relative to it, as `page.goto` resolves them */
17
36
  baseURL?: string;
37
+ /** Shows the recorder toolbar in the page (pause, assert, insert, wait, screenshot, stop). Without it, no toolbar */
38
+ toolbar?: ToolbarOptions;
18
39
  }
19
40
  /**
20
41
  * Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
@@ -27,26 +48,59 @@ export declare class Recorder {
27
48
  private readonly context;
28
49
  private readonly normaliser;
29
50
  private readonly options;
51
+ /** The capture script's channel: page messages only */
30
52
  private readonly channel;
53
+ /** The toolbar's channel, handed only to the toolbar's sandbox: toolbar messages only */
54
+ private readonly toolbarChannel;
31
55
  private readonly queue;
32
56
  private readonly known;
33
57
  private stopped;
58
+ private stopping;
59
+ private controller;
34
60
  private constructor();
61
+ /** The context's `samurai-toolbar` sandbox realms, as last listed: the only realms toolbar messages are taken from */
62
+ private toolbarRealms;
63
+ /** Toolbar messages waiting for a realm lookup, and those that arrived behind them (kept in order) */
64
+ private toolbarBacklog;
65
+ private toolbarWaiting;
35
66
  static start(target: {
36
67
  page: Page;
37
68
  connector: BiDiConnector;
38
69
  context: string;
39
70
  }, options: RecorderOptions): Promise<Recorder>;
71
+ /**
72
+ * Page messages come only on the capture channel; toolbar messages only on the toolbar channel and
73
+ * only from the toolbar's sandbox realm. Neither channel is reachable from page script (each stays in
74
+ * its script's closure, and the toolbar's in a sandbox the page cannot see); the realm check also
75
+ * holds if a page realm ever got the toolbar's channel.
76
+ */
40
77
  private readonly onMessage;
78
+ private listToolbarRealms;
79
+ /** A message from the toolbar's own sandbox */
80
+ private fromToolbar;
81
+ private readonly onLoad;
41
82
  private readonly onNavigation;
42
83
  private subscription;
43
84
  private preload;
85
+ private toolbarPreload;
44
86
  private begin;
87
+ private createController;
88
+ /** Calls the toolbar hook's `method` in the loaded document; a document between loads has no toolbar yet */
89
+ private callToolbar;
45
90
  private enqueue;
46
91
  private emit;
47
92
  private locatorFor;
48
93
  private handle;
49
94
  private release;
95
+ /**
96
+ * Pauses capture in the loaded document: clicks and typing are not reported until `unmute`.
97
+ * @internal
98
+ */
99
+ mute(): Promise<void>;
100
+ /** @internal */
101
+ unmute(): Promise<void>;
102
+ private setMuted;
50
103
  /** Stops recording. Events already received are still processed and reported first */
51
104
  stop(): Promise<void>;
105
+ private end;
52
106
  }