@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.
- package/README.md +2 -2
- package/bin/samurai.mjs +25 -0
- package/dist/browser/evidence.d.ts +19 -0
- package/dist/browser/evidence.js +35 -0
- package/dist/browser/page.d.ts +6 -0
- package/dist/browser/page.js +20 -3
- package/dist/browser/screenshot.d.ts +2 -0
- package/dist/browser/screenshot.js +5 -2
- package/dist/cli/browsers.d.ts +1 -1
- package/dist/cli/browsers.js +2 -4
- package/dist/cli/main.js +15 -1
- package/dist/project/config-edit.d.ts +16 -0
- package/dist/project/config-edit.js +218 -0
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/secrets-file.d.ts +8 -0
- package/dist/project/secrets-file.js +69 -0
- package/dist/project/variables.d.ts +6 -0
- package/dist/project/variables.js +17 -0
- package/dist/recorder/candidates.d.ts +7 -0
- package/dist/recorder/candidates.js +118 -0
- package/dist/recorder/capture.d.ts +8 -1
- package/dist/recorder/capture.js +25 -116
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/normalise.d.ts +28 -1
- package/dist/recorder/normalise.js +40 -1
- package/dist/recorder/recorder.d.ts +54 -0
- package/dist/recorder/recorder.js +195 -10
- package/dist/recorder/session.d.ts +15 -0
- package/dist/recorder/session.js +57 -7
- package/dist/recorder/toolbar/controller.d.ts +136 -0
- package/dist/recorder/toolbar/controller.js +471 -0
- package/dist/recorder/toolbar/inspect.d.ts +12 -0
- package/dist/recorder/toolbar/inspect.js +94 -0
- package/dist/recorder/toolbar/protocol.d.ts +135 -0
- package/dist/recorder/toolbar/protocol.js +4 -0
- package/dist/recorder/toolbar/source.d.ts +32 -0
- package/dist/recorder/toolbar/source.js +856 -0
- package/dist/recorder/toolbar/styles.d.ts +6 -0
- package/dist/recorder/toolbar/styles.js +95 -0
- package/dist/runner/reporter.d.ts +5 -1
- package/dist/runner/reporter.js +4 -3
- package/dist/runner/test-runner.d.ts +9 -0
- package/dist/runner/test-runner.js +39 -4
- package/dist/steps/emit.js +4 -0
- package/dist/steps/model.d.ts +11 -0
- package/dist/steps/parse.js +22 -3
- package/dist/transport/bidi-connection.js +32 -1
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/test.d.ts +4 -0
- 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.
|
|
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; }";
|
package/dist/recorder/capture.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
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; }`;
|
package/dist/recorder/index.d.ts
CHANGED
|
@@ -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
|
}
|