@itmetsam/samurai-framework 0.1.0 → 0.2.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 +129 -0
- package/bin/samurai.mjs +31 -0
- package/dist/api.js +3 -0
- package/dist/assert/assertion-error.d.ts +23 -0
- package/dist/assert/assertion-error.js +45 -0
- package/dist/assert/expect.d.ts +55 -0
- package/dist/assert/expect.js +188 -0
- package/dist/browser/browser-context.d.ts +91 -0
- package/dist/browser/browser-context.js +146 -0
- package/dist/browser/browser-finder.d.ts +2 -0
- package/dist/browser/browser-finder.js +95 -0
- package/dist/browser/browser.d.ts +95 -0
- package/dist/browser/browser.js +372 -0
- package/dist/browser/context-tree.d.ts +23 -0
- package/dist/browser/context-tree.js +60 -0
- package/dist/browser/dialog.d.ts +37 -0
- package/dist/browser/dialog.js +61 -0
- package/dist/browser/download.d.ts +71 -0
- package/dist/browser/download.js +137 -0
- package/dist/browser/downloads-dir.d.ts +8 -0
- package/dist/browser/downloads-dir.js +38 -0
- package/dist/browser/emulation.d.ts +61 -0
- package/dist/browser/emulation.js +144 -0
- package/dist/browser/file-chooser.d.ts +39 -0
- package/dist/browser/file-chooser.js +86 -0
- package/dist/browser/file-paths.d.ts +4 -0
- package/dist/browser/file-paths.js +18 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/navigation-error.d.ts +13 -0
- package/dist/browser/navigation-error.js +19 -0
- package/dist/browser/page-logs.d.ts +86 -0
- package/dist/browser/page-logs.js +149 -0
- package/dist/browser/page-wait.d.ts +9 -0
- package/dist/browser/page-wait.js +17 -0
- package/dist/browser/page.d.ts +370 -0
- package/dist/browser/page.js +1038 -0
- package/dist/browser/pdf.d.ts +72 -0
- package/dist/browser/pdf.js +37 -0
- package/dist/browser/screenshot.d.ts +29 -0
- package/dist/browser/screenshot.js +42 -0
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.js +69 -0
- package/dist/cli/cli.cli-test.d.ts +1 -0
- package/dist/cli/cli.cli-test.js +170 -0
- package/dist/cli/console-reporter.d.ts +15 -0
- package/dist/cli/console-reporter.js +52 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +61 -0
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.js +156 -0
- package/dist/config/config.d.ts +24 -0
- package/dist/config/config.js +42 -0
- package/dist/config/default.d.ts +2 -0
- package/dist/config/default.js +3 -0
- package/dist/config/mask.d.ts +11 -0
- package/dist/config/mask.js +85 -0
- package/dist/config/run-settings.d.ts +28 -0
- package/dist/config/run-settings.js +61 -0
- package/dist/config/secrets.d.ts +10 -0
- package/dist/config/secrets.js +67 -0
- package/dist/config/variables.d.ts +11 -0
- package/dist/config/variables.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/locator/action-timeout-error.d.ts +25 -0
- package/dist/locator/action-timeout-error.js +35 -0
- package/dist/locator/element-state.d.ts +44 -0
- package/dist/locator/element-state.js +109 -0
- package/dist/locator/label-locator.d.ts +13 -0
- package/dist/locator/label-locator.js +49 -0
- package/dist/locator/locator.d.ts +168 -0
- package/dist/locator/locator.js +591 -0
- package/dist/locator/selector-errors.d.ts +9 -0
- package/dist/locator/selector-errors.js +15 -0
- package/dist/locator/selector.d.ts +60 -0
- package/dist/locator/selector.js +113 -0
- package/dist/locator/text-locator.d.ts +13 -0
- package/dist/logger/index.d.ts +2 -0
- package/dist/logger/index.js +30 -0
- package/dist/network/data-collector.d.ts +26 -0
- package/dist/network/data-collector.js +77 -0
- package/dist/network/network-tracker.d.ts +94 -0
- package/dist/network/network-tracker.js +198 -0
- package/dist/network/network-wait.d.ts +16 -0
- package/dist/network/network-wait.js +24 -0
- package/dist/network/response.d.ts +22 -0
- package/dist/network/response.js +42 -0
- package/dist/network/router.d.ts +120 -0
- package/dist/network/router.js +343 -0
- package/dist/network/url-match.d.ts +8 -0
- package/dist/network/url-match.js +38 -0
- package/dist/recorder/apply.d.ts +3 -0
- package/dist/recorder/apply.js +10 -0
- package/dist/recorder/capture.d.ts +18 -0
- package/dist/recorder/capture.js +198 -0
- package/dist/recorder/index.d.ts +3 -0
- package/dist/recorder/index.js +3 -0
- package/dist/recorder/normalise.d.ts +68 -0
- package/dist/recorder/normalise.js +104 -0
- package/dist/recorder/ranking.d.ts +9 -0
- package/dist/recorder/ranking.js +21 -0
- package/dist/recorder/recorder.d.ts +52 -0
- package/dist/recorder/recorder.js +143 -0
- package/dist/recorder/session.d.ts +49 -0
- package/dist/recorder/session.js +113 -0
- package/dist/runner/module-type.d.ts +6 -0
- package/dist/runner/module-type.js +29 -0
- package/dist/runner/page-logs-report.d.ts +40 -0
- package/dist/runner/page-logs-report.js +44 -0
- package/dist/runner/prepare-run.d.ts +14 -0
- package/dist/runner/prepare-run.js +20 -0
- package/dist/runner/project.d.ts +17 -0
- package/dist/runner/project.js +33 -0
- package/dist/runner/registry.d.ts +7 -0
- package/dist/runner/registry.js +40 -0
- package/dist/runner/reporter.d.ts +43 -0
- package/dist/runner/reporter.js +83 -0
- package/dist/runner/run.d.ts +27 -0
- package/dist/runner/run.js +34 -0
- package/dist/runner/test-error.d.ts +3 -0
- package/dist/runner/test-error.js +18 -0
- package/dist/runner/test-runner.d.ts +44 -0
- package/dist/runner/test-runner.js +205 -0
- package/dist/script/call-function.d.ts +34 -0
- package/dist/script/call-function.js +71 -0
- package/dist/script/element-handle.d.ts +10 -0
- package/{src/script/element-handle.ts → dist/script/element-handle.js} +4 -4
- package/dist/script/helpers.d.ts +28 -0
- package/dist/script/helpers.js +50 -0
- package/dist/script/preload.d.ts +31 -0
- package/dist/script/preload.js +71 -0
- package/dist/script/serialize.d.ts +29 -0
- package/dist/script/serialize.js +207 -0
- package/dist/steps/build.d.ts +5 -0
- package/dist/steps/build.js +38 -0
- package/dist/steps/edit.d.ts +30 -0
- package/dist/steps/edit.js +80 -0
- package/dist/steps/emit.d.ts +6 -0
- package/dist/steps/emit.js +73 -0
- package/dist/steps/index.d.ts +5 -0
- package/dist/steps/index.js +4 -0
- package/dist/steps/model.d.ts +103 -0
- package/dist/steps/model.js +1 -0
- package/dist/steps/parse.d.ts +3 -0
- package/dist/steps/parse.js +272 -0
- package/dist/transport/bidi-connection.d.ts +46 -0
- package/dist/transport/bidi-connection.js +176 -0
- package/dist/transport/bidi-error.d.ts +10 -0
- package/dist/transport/bidi-error.js +10 -0
- package/dist/transport/subscriptions.d.ts +36 -0
- package/dist/transport/subscriptions.js +81 -0
- package/{src → dist}/types/test.d.ts +10 -4
- package/dist/wait/wait-until.d.ts +20 -0
- package/dist/wait/wait-until.js +63 -0
- package/package.json +44 -7
- package/.claude/settings.local.json +0 -7
- package/.env.example +0 -3
- package/.github/actions/setup/action.yml +0 -16
- package/.github/workflows/ci.yml +0 -71
- package/.trunk/configs/.markdownlint.yaml +0 -2
- package/.trunk/configs/.yamllint.yaml +0 -7
- package/.trunk/configs/svgo.config.mjs +0 -14
- package/.trunk/trunk.yaml +0 -34
- package/docs/superpowers/bidi-roadmap.md +0 -60
- package/docs/superpowers/plans/2026-09-29-auto-wait.md +0 -1827
- package/docs/superpowers/plans/2026-09-29-expect.md +0 -1350
- package/docs/superpowers/plans/2026-10-01-ci.md +0 -350
- package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +0 -1821
- package/docs/superpowers/recorder-roadmap.md +0 -52
- package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +0 -185
- package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +0 -94
- package/docs/superpowers/specs/2026-09-29-emulation-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-expect-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +0 -87
- package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +0 -83
- package/docs/superpowers/specs/2026-09-29-network-data-design.md +0 -78
- package/docs/superpowers/specs/2026-09-29-network-interception-design.md +0 -91
- package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +0 -86
- package/docs/superpowers/specs/2026-09-29-page-api-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +0 -82
- package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +0 -107
- package/docs/superpowers/specs/2026-09-29-set-files-design.md +0 -62
- package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +0 -75
- package/docs/superpowers/specs/2026-10-01-ci-design.md +0 -106
- package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +0 -241
- package/public/logo-dark.svg +0 -236
- package/public/logo-light.svg +0 -262
- package/samurai.config.ts +0 -7
- package/src/assert/assertion-error.test.ts +0 -55
- package/src/assert/assertion-error.ts +0 -62
- package/src/assert/expect-locator.test.ts +0 -217
- package/src/assert/expect.test.ts +0 -73
- package/src/assert/expect.ts +0 -299
- package/src/browser/browser-context.browser-test.ts +0 -201
- package/src/browser/browser-context.test.ts +0 -407
- package/src/browser/browser-context.ts +0 -208
- package/src/browser/browser-finder.ts +0 -110
- package/src/browser/browser.test.ts +0 -88
- package/src/browser/browser.ts +0 -515
- package/src/browser/context-tree.test.ts +0 -86
- package/src/browser/context-tree.ts +0 -70
- package/src/browser/dialog.browser-test.ts +0 -144
- package/src/browser/dialog.test.ts +0 -322
- package/src/browser/dialog.ts +0 -83
- package/src/browser/download.test.ts +0 -161
- package/src/browser/download.ts +0 -172
- package/src/browser/downloads-dir.test.ts +0 -75
- package/src/browser/downloads-dir.ts +0 -39
- package/src/browser/emulation.browser-test.ts +0 -186
- package/src/browser/emulation.test.ts +0 -200
- package/src/browser/emulation.ts +0 -204
- package/src/browser/file-chooser.test.ts +0 -142
- package/src/browser/file-chooser.ts +0 -99
- package/src/browser/file-paths.ts +0 -18
- package/src/browser/goto.browser-test.ts +0 -44
- package/src/browser/navigation-error.ts +0 -26
- package/src/browser/navigation.browser-test.ts +0 -172
- package/src/browser/page-api.browser-test.ts +0 -228
- package/src/browser/page-logs.browser-test.ts +0 -119
- package/src/browser/page-logs.test.ts +0 -212
- package/src/browser/page-logs.ts +0 -208
- package/src/browser/page-wait.ts +0 -19
- package/src/browser/page.test.ts +0 -1567
- package/src/browser/page.ts +0 -1176
- package/src/browser/pdf.ts +0 -62
- package/src/browser/screenshot.ts +0 -65
- package/src/browser/transfers.browser-test.ts +0 -180
- package/src/config/config.ts +0 -33
- package/src/config/default.ts +0 -5
- package/src/config/fixtures.test.ts +0 -137
- package/src/config/mask.test.ts +0 -143
- package/src/config/mask.ts +0 -87
- package/src/config/run-settings.test.ts +0 -163
- package/src/config/run-settings.ts +0 -114
- package/src/config/secrets.ts +0 -70
- package/src/config/variables.ts +0 -31
- package/src/index.ts +0 -10
- package/src/locator/action-timeout-error.test.ts +0 -83
- package/src/locator/action-timeout-error.ts +0 -72
- package/src/locator/element-state.test.ts +0 -181
- package/src/locator/element-state.ts +0 -159
- package/src/locator/locate-nodes.browser-test.ts +0 -169
- package/src/locator/locator-actions.test.ts +0 -355
- package/src/locator/locator-chain.test.ts +0 -292
- package/src/locator/locator.browser-test.ts +0 -137
- package/src/locator/locator.test.ts +0 -179
- package/src/locator/locator.ts +0 -656
- package/src/locator/selector-errors.ts +0 -18
- package/src/locator/selector.test.ts +0 -124
- package/src/locator/selector.ts +0 -107
- package/src/locator/set-files.browser-test.ts +0 -141
- package/src/locator/set-files.test.ts +0 -191
- package/src/locator/text-locator.test.ts +0 -118
- package/src/logger/index.test.ts +0 -28
- package/src/logger/index.ts +0 -40
- package/src/network/data-collector.test.ts +0 -111
- package/src/network/data-collector.ts +0 -92
- package/src/network/network-data.browser-test.ts +0 -192
- package/src/network/network-tracker.browser-test.ts +0 -151
- package/src/network/network-tracker.test.ts +0 -307
- package/src/network/network-tracker.ts +0 -286
- package/src/network/network-wait.ts +0 -28
- package/src/network/response.test.ts +0 -93
- package/src/network/response.ts +0 -58
- package/src/network/router.browser-test.ts +0 -260
- package/src/network/router.test.ts +0 -528
- package/src/network/router.ts +0 -420
- package/src/network/url-match.test.ts +0 -69
- package/src/network/url-match.ts +0 -41
- package/src/runner/page-logs-report.test.ts +0 -144
- package/src/runner/page-logs-report.ts +0 -82
- package/src/runner/prepare-run.test.ts +0 -33
- package/src/runner/prepare-run.ts +0 -36
- package/src/runner/registry.test.ts +0 -59
- package/src/runner/registry.ts +0 -46
- package/src/runner/reporter.test.ts +0 -73
- package/src/runner/reporter.ts +0 -109
- package/src/runner/test-error.test.ts +0 -39
- package/src/runner/test-error.ts +0 -20
- package/src/runner/test-runner.ts +0 -184
- package/src/script/call-function.browser-test.ts +0 -112
- package/src/script/call-function.test.ts +0 -174
- package/src/script/call-function.ts +0 -113
- package/src/script/helpers.test.ts +0 -116
- package/src/script/helpers.ts +0 -68
- package/src/script/preload.browser-test.ts +0 -144
- package/src/script/preload.test.ts +0 -180
- package/src/script/preload.ts +0 -102
- package/src/script/serialize.test.ts +0 -182
- package/src/script/serialize.ts +0 -203
- package/src/testing/browser-fixture.ts +0 -18
- package/src/testing/fake-websocket.ts +0 -72
- package/src/testing/stub-connector.ts +0 -154
- package/src/tests/index.spec.ts +0 -30
- package/src/transport/bidi-connection.test.ts +0 -155
- package/src/transport/bidi-connection.ts +0 -256
- package/src/transport/bidi-error.ts +0 -23
- package/src/transport/foundation.browser-test.ts +0 -73
- package/src/transport/subscriptions.test.ts +0 -140
- package/src/transport/subscriptions.ts +0 -110
- package/src/types/bidi-commands.test-d.ts +0 -55
- package/src/wait/wait-until.test.ts +0 -117
- package/src/wait/wait-until.ts +0 -86
- package/tsconfig.json +0 -44
- /package/{src/api.ts → dist/api.d.ts} +0 -0
- /package/{src/locator/text-locator.ts → dist/locator/text-locator.js} +0 -0
- /package/{src → dist}/types/bidi-modules/browser.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/browsing-context.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/emulation.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/input.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/log.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/network.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/permissions.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/script.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/session.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/storage.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/web-extensions.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/command.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/error.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/event.d.ts +0 -0
- /package/{src → dist}/types/bidi.d.ts +0 -0
- /package/{src → dist}/types/browser.d.ts +0 -0
- /package/{src → dist}/types/config.d.ts +0 -0
- /package/{src → dist}/types/env.d.ts +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Name of the window flag that tells the capture script to stop reporting (see `CAPTURE_OFF`) */
|
|
2
|
+
const OFF_KEY = "samurai.recorder.off";
|
|
3
|
+
/**
|
|
4
|
+
* 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:
|
|
6
|
+
*
|
|
7
|
+
* - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
|
|
8
|
+
* element (a label click reports its control; the click the browser forwards to the control is dropped).
|
|
9
|
+
* - `{ type: "input", target, candidates, value, secret, secretName }`: text typed into a field. Password
|
|
10
|
+
* values never leave the page: `secret` is set and `value` is left out.
|
|
11
|
+
* - `{ type: "assert", target, candidates, text, value }`: an Alt+click, which does not reach the page.
|
|
12
|
+
* While Alt is held, the element under the pointer is outlined.
|
|
13
|
+
*
|
|
14
|
+
* `candidates` are locator calls for the element, most stable first (test id, role and name, label,
|
|
15
|
+
* text, css, and an absolute xpath that always exists). The recorder checks each one against the page.
|
|
16
|
+
* Runs in the page's own realm; a string, so no transpiler helper can end up in it.
|
|
17
|
+
*/
|
|
18
|
+
export const CAPTURE_SOURCE = String.raw `(send) => {
|
|
19
|
+
const OFF = Symbol.for("${OFF_KEY}");
|
|
20
|
+
const ON = Symbol.for("samurai.recorder.on");
|
|
21
|
+
const SEND = Symbol.for("samurai.recorder.send");
|
|
22
|
+
if (window !== window.top) return;
|
|
23
|
+
// A document that was recorded before gets the new channel; its listeners are already in place
|
|
24
|
+
window[OFF] = false;
|
|
25
|
+
window[SEND] = send;
|
|
26
|
+
if (window[ON]) return;
|
|
27
|
+
window[ON] = true;
|
|
28
|
+
const report = (message) => window[SEND](message);
|
|
29
|
+
|
|
30
|
+
const norm = (text) => String(text == null ? "" : text).replace(/\s+/g, " ").trim();
|
|
31
|
+
const textOf = (node) => norm(node.innerText != null ? node.innerText : node.textContent);
|
|
32
|
+
const NO_ROLE_TYPES = ["hidden", "password", "file", "color", "date", "datetime-local", "month", "time", "week"];
|
|
33
|
+
const TEXT_TYPES = ["", "text", "email", "search", "tel", "url", "password", "number"];
|
|
34
|
+
|
|
35
|
+
const roleOf = (el) => {
|
|
36
|
+
const explicit = norm(el.getAttribute("role")).split(" ")[0];
|
|
37
|
+
if (explicit) return explicit;
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
if (tag === "a") return el.hasAttribute("href") ? "link" : undefined;
|
|
40
|
+
if (tag === "button" || tag === "summary") return "button";
|
|
41
|
+
if (tag === "select") return el.multiple || el.size > 1 ? "listbox" : "combobox";
|
|
42
|
+
if (tag === "textarea") return "textbox";
|
|
43
|
+
if (/^h[1-6]$/.test(tag)) return "heading";
|
|
44
|
+
if (tag === "img") return "img";
|
|
45
|
+
if (tag === "input") {
|
|
46
|
+
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
47
|
+
if (NO_ROLE_TYPES.indexOf(type) >= 0) return undefined;
|
|
48
|
+
if (type === "checkbox" || type === "radio") return type;
|
|
49
|
+
if (type === "button" || type === "submit" || type === "reset" || type === "image") return "button";
|
|
50
|
+
if (type === "range") return "slider";
|
|
51
|
+
if (type === "number") return "spinbutton";
|
|
52
|
+
if (type === "search") return "searchbox";
|
|
53
|
+
return "textbox";
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const labelTexts = (el) => {
|
|
59
|
+
const texts = [];
|
|
60
|
+
if (el.labels) for (const label of el.labels) texts.push(textOf(label));
|
|
61
|
+
const by = el.getAttribute("aria-labelledby");
|
|
62
|
+
if (by) {
|
|
63
|
+
for (const id of by.split(/\s+/)) {
|
|
64
|
+
const named = id && document.getElementById(id);
|
|
65
|
+
if (named) texts.push(textOf(named));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const own = el.getAttribute("aria-label");
|
|
69
|
+
if (own) texts.push(norm(own));
|
|
70
|
+
return texts.filter(Boolean);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const nameOf = (el, role) => {
|
|
74
|
+
const labelled = labelTexts(el)[0];
|
|
75
|
+
if (labelled) return labelled;
|
|
76
|
+
const tag = el.tagName.toLowerCase();
|
|
77
|
+
if (tag === "input") {
|
|
78
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
79
|
+
return type === "submit" || type === "button" || type === "reset" ? norm(el.value) : "";
|
|
80
|
+
}
|
|
81
|
+
if (tag === "img") return norm(el.getAttribute("alt"));
|
|
82
|
+
if (role === "textbox" || role === "combobox" || role === "listbox") return "";
|
|
83
|
+
return textOf(el) || norm(el.getAttribute("title"));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const plain = (value) => value.indexOf('"') < 0 && value.indexOf("\\") < 0;
|
|
87
|
+
|
|
88
|
+
const xpathOf = (el) => {
|
|
89
|
+
const parts = [];
|
|
90
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
91
|
+
let index = 1;
|
|
92
|
+
for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
|
|
93
|
+
if (sibling.tagName === node.tagName) index++;
|
|
94
|
+
}
|
|
95
|
+
parts.unshift(node.tagName.toLowerCase() + "[" + index + "]");
|
|
96
|
+
}
|
|
97
|
+
return "/" + parts.join("/");
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const candidatesOf = (el) => {
|
|
101
|
+
const found = [];
|
|
102
|
+
const tag = el.tagName.toLowerCase();
|
|
103
|
+
const testId = el.getAttribute("data-testid");
|
|
104
|
+
if (testId) found.push({ method: "getByTestId", testId });
|
|
105
|
+
const role = roleOf(el);
|
|
106
|
+
if (role) {
|
|
107
|
+
const name = nameOf(el, role);
|
|
108
|
+
if (name && name.length <= 80) found.push({ method: "getByRole", role, name });
|
|
109
|
+
}
|
|
110
|
+
const label = labelTexts(el)[0];
|
|
111
|
+
if (label && label.length <= 80 && el.labels !== undefined) found.push({ method: "getByLabel", text: label });
|
|
112
|
+
const text = textOf(el);
|
|
113
|
+
if (text && text.length <= 60 && tag !== "input" && tag !== "textarea" && tag !== "select") {
|
|
114
|
+
found.push({ method: "getByText", text });
|
|
115
|
+
}
|
|
116
|
+
if (el.id && /^[A-Za-z][\w-]*$/.test(el.id) && !/\d{3,}/.test(el.id)) found.push({ method: "getByCss", css: "#" + el.id });
|
|
117
|
+
const name = el.getAttribute("name");
|
|
118
|
+
if (name && plain(name)) found.push({ method: "getByCss", css: tag + '[name="' + name + '"]' });
|
|
119
|
+
const placeholder = el.getAttribute("placeholder");
|
|
120
|
+
if (placeholder && plain(placeholder)) found.push({ method: "getByCss", css: tag + '[placeholder="' + placeholder + '"]' });
|
|
121
|
+
found.push({ method: "locator", xpath: xpathOf(el) });
|
|
122
|
+
return found;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const isTextEntry = (el) => {
|
|
126
|
+
const tag = el.tagName.toLowerCase();
|
|
127
|
+
if (tag === "textarea") return true;
|
|
128
|
+
if (tag === "input") return TEXT_TYPES.indexOf((el.getAttribute("type") || "").toLowerCase()) >= 0;
|
|
129
|
+
return Boolean(el.isContentEditable);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
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]";
|
|
133
|
+
const interactiveAt = (target) => {
|
|
134
|
+
const found = target.closest(INTERACTIVE) || target;
|
|
135
|
+
return found.tagName === "LABEL" && found.control ? found.control : found;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const off = () => window[OFF] === true;
|
|
139
|
+
let lastClick = { el: null, at: 0 };
|
|
140
|
+
let outlined = null;
|
|
141
|
+
let outlinedStyle = "";
|
|
142
|
+
const unhighlight = () => {
|
|
143
|
+
if (outlined) outlined.style.outline = outlinedStyle;
|
|
144
|
+
outlined = null;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
document.addEventListener("click", (event) => {
|
|
148
|
+
if (off() || !event.isTrusted || !(event.target instanceof Element)) return;
|
|
149
|
+
if (event.altKey) {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
event.stopImmediatePropagation();
|
|
152
|
+
unhighlight();
|
|
153
|
+
const el = event.target;
|
|
154
|
+
const hasValue = typeof el.value === "string" && el.tagName !== "BUTTON" && el.tagName !== "LI";
|
|
155
|
+
const secret = el.tagName === "INPUT" && (el.getAttribute("type") || "").toLowerCase() === "password";
|
|
156
|
+
report({
|
|
157
|
+
type: "assert",
|
|
158
|
+
target: el,
|
|
159
|
+
candidates: candidatesOf(el),
|
|
160
|
+
text: norm(el.textContent),
|
|
161
|
+
...(hasValue && !secret && { value: el.value }),
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const el = interactiveAt(event.target);
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
// The browser forwards a label click to its control as a second click
|
|
168
|
+
if (lastClick.el === el && now - lastClick.at < 150) return;
|
|
169
|
+
lastClick = { el, at: now };
|
|
170
|
+
report({ type: "click", target: el, candidates: candidatesOf(el), textEntry: isTextEntry(el) });
|
|
171
|
+
}, true);
|
|
172
|
+
|
|
173
|
+
document.addEventListener("input", (event) => {
|
|
174
|
+
const el = event.target;
|
|
175
|
+
if (off() || !event.isTrusted || !(el instanceof Element) || !isTextEntry(el) || el.isContentEditable) return;
|
|
176
|
+
const secret = el.tagName === "INPUT" && (el.getAttribute("type") || "").toLowerCase() === "password";
|
|
177
|
+
report({
|
|
178
|
+
type: "input",
|
|
179
|
+
target: el,
|
|
180
|
+
candidates: candidatesOf(el),
|
|
181
|
+
...(secret
|
|
182
|
+
? { secret: true, secretName: el.getAttribute("name") || el.id || "" }
|
|
183
|
+
: { value: el.value }),
|
|
184
|
+
});
|
|
185
|
+
}, true);
|
|
186
|
+
|
|
187
|
+
document.addEventListener("mousemove", (event) => {
|
|
188
|
+
if (off() || !event.altKey || !(event.target instanceof Element) || event.target === outlined) return;
|
|
189
|
+
unhighlight();
|
|
190
|
+
outlined = event.target;
|
|
191
|
+
outlinedStyle = outlined.style.outline;
|
|
192
|
+
outlined.style.outline = "2px solid #e5484d";
|
|
193
|
+
}, true);
|
|
194
|
+
document.addEventListener("keyup", (event) => { if (event.key === "Alt") unhighlight(); }, true);
|
|
195
|
+
window.addEventListener("blur", unhighlight);
|
|
196
|
+
}`;
|
|
197
|
+
/** Run in every loaded document to stop its capture script reporting and swallowing Alt+clicks */
|
|
198
|
+
export const CAPTURE_OFF = `() => { window[Symbol.for("${OFF_KEY}")] = true; }`;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { LocatorSpec, Step } from "../steps/model.js";
|
|
2
|
+
/** What the recorder does to the step list: a new step at `index`, or a step rewritten in place */
|
|
3
|
+
export type RecorderEvent = {
|
|
4
|
+
op: "insert";
|
|
5
|
+
index: number;
|
|
6
|
+
step: Step;
|
|
7
|
+
} | {
|
|
8
|
+
op: "replace";
|
|
9
|
+
index: number;
|
|
10
|
+
step: Step;
|
|
11
|
+
};
|
|
12
|
+
/** A page event whose element already has its locators */
|
|
13
|
+
export type Observation = {
|
|
14
|
+
type: "click";
|
|
15
|
+
key: string;
|
|
16
|
+
locator: LocatorSpec;
|
|
17
|
+
textEntry: boolean;
|
|
18
|
+
} | {
|
|
19
|
+
type: "input";
|
|
20
|
+
key: string;
|
|
21
|
+
locator: LocatorSpec;
|
|
22
|
+
value?: string;
|
|
23
|
+
secret?: boolean;
|
|
24
|
+
secretName?: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: "assert";
|
|
27
|
+
key: string;
|
|
28
|
+
locator: LocatorSpec;
|
|
29
|
+
text: string;
|
|
30
|
+
value?: string;
|
|
31
|
+
};
|
|
32
|
+
export interface NormaliserOptions {
|
|
33
|
+
/** Index the first recorded step gets */
|
|
34
|
+
at: number;
|
|
35
|
+
/** Relative URLs are written against this */
|
|
36
|
+
baseURL?: string;
|
|
37
|
+
/** A navigation within this many ms of the last action is a consequence of it. @default 2000 */
|
|
38
|
+
navigationWindow?: number;
|
|
39
|
+
}
|
|
40
|
+
/** The name a secret gets in `secrets.<NAME>`: the field's name or id, upper snake case */
|
|
41
|
+
export declare function secretNameFor(hint: string | undefined): string;
|
|
42
|
+
/**
|
|
43
|
+
* Turns what the tester did into steps:
|
|
44
|
+
* - typing into a field becomes one `fill`, rewritten as the text grows (a password becomes a secret reference);
|
|
45
|
+
* - clicks that only focus a text field are dropped;
|
|
46
|
+
* - a navigation right after an action becomes `waitForNetworkIdle`, any other navigation a `goto`;
|
|
47
|
+
* - an Alt+click becomes an `expect`.
|
|
48
|
+
* Pure: the caller feeds it events and the times they happened.
|
|
49
|
+
*/
|
|
50
|
+
export declare class Normaliser {
|
|
51
|
+
private readonly options;
|
|
52
|
+
private next;
|
|
53
|
+
private lastFill;
|
|
54
|
+
private lastAction;
|
|
55
|
+
private lastStep;
|
|
56
|
+
private readonly window;
|
|
57
|
+
constructor(options: NormaliserOptions);
|
|
58
|
+
/** Index the next inserted step gets */
|
|
59
|
+
get nextIndex(): number;
|
|
60
|
+
private insert;
|
|
61
|
+
/** The path (and query) of `url` when it is inside the base URL, else `url` */
|
|
62
|
+
relative(url: string): string;
|
|
63
|
+
observe(observation: Observation, at: number): RecorderEvent[];
|
|
64
|
+
/** A top-level navigation started */
|
|
65
|
+
navigated(url: string, at: number): RecorderEvent[];
|
|
66
|
+
/** The page the recording starts on, written as a `goto` */
|
|
67
|
+
initialGoto(url: string): RecorderEvent[];
|
|
68
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const TEXT_LIMIT = 100;
|
|
2
|
+
/** The name a secret gets in `secrets.<NAME>`: the field's name or id, upper snake case */
|
|
3
|
+
export function secretNameFor(hint) {
|
|
4
|
+
const name = (hint ?? "")
|
|
5
|
+
.replace(/([a-z\d])([A-Z])/g, "$1_$2")
|
|
6
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
7
|
+
.replace(/^_+|_+$/g, "")
|
|
8
|
+
.toUpperCase();
|
|
9
|
+
return name === "" || /^\d/.test(name) ? "PASSWORD" : name;
|
|
10
|
+
}
|
|
11
|
+
function expectationFor(text, value) {
|
|
12
|
+
if (value !== undefined)
|
|
13
|
+
return { matcher: "toHaveValue", expected: value };
|
|
14
|
+
if (text === "")
|
|
15
|
+
return { matcher: "toBeVisible" };
|
|
16
|
+
return text.length <= TEXT_LIMIT
|
|
17
|
+
? { matcher: "toHaveText", expected: text }
|
|
18
|
+
: { matcher: "toContainText", expected: text.slice(0, TEXT_LIMIT).trim() };
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Turns what the tester did into steps:
|
|
22
|
+
* - typing into a field becomes one `fill`, rewritten as the text grows (a password becomes a secret reference);
|
|
23
|
+
* - clicks that only focus a text field are dropped;
|
|
24
|
+
* - a navigation right after an action becomes `waitForNetworkIdle`, any other navigation a `goto`;
|
|
25
|
+
* - an Alt+click becomes an `expect`.
|
|
26
|
+
* Pure: the caller feeds it events and the times they happened.
|
|
27
|
+
*/
|
|
28
|
+
export class Normaliser {
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.lastAction = -Infinity;
|
|
32
|
+
this.next = options.at;
|
|
33
|
+
this.window = options.navigationWindow ?? 2000;
|
|
34
|
+
}
|
|
35
|
+
/** Index the next inserted step gets */
|
|
36
|
+
get nextIndex() {
|
|
37
|
+
return this.next;
|
|
38
|
+
}
|
|
39
|
+
insert(step) {
|
|
40
|
+
this.lastStep = step;
|
|
41
|
+
return { op: "insert", index: this.next++, step };
|
|
42
|
+
}
|
|
43
|
+
/** The path (and query) of `url` when it is inside the base URL, else `url` */
|
|
44
|
+
relative(url) {
|
|
45
|
+
const base = this.options.baseURL?.replace(/\/+$/, "");
|
|
46
|
+
if (base &&
|
|
47
|
+
(url === base ||
|
|
48
|
+
url.startsWith(`${base}/`) ||
|
|
49
|
+
url.startsWith(`${base}?`) ||
|
|
50
|
+
url.startsWith(`${base}#`))) {
|
|
51
|
+
return url.slice(base.length) || "/";
|
|
52
|
+
}
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
observe(observation, at) {
|
|
56
|
+
if (observation.type === "click") {
|
|
57
|
+
if (observation.textEntry)
|
|
58
|
+
return []; // focusing a field: the fill that follows needs no click
|
|
59
|
+
this.lastFill = undefined;
|
|
60
|
+
this.lastAction = at;
|
|
61
|
+
return [this.insert({ kind: "click", locator: observation.locator })];
|
|
62
|
+
}
|
|
63
|
+
if (observation.type === "assert") {
|
|
64
|
+
this.lastFill = undefined;
|
|
65
|
+
return [
|
|
66
|
+
this.insert({
|
|
67
|
+
kind: "expect",
|
|
68
|
+
locator: observation.locator,
|
|
69
|
+
not: false,
|
|
70
|
+
expectation: expectationFor(observation.text, observation.value),
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
this.lastAction = at;
|
|
75
|
+
const step = {
|
|
76
|
+
kind: "fill",
|
|
77
|
+
locator: observation.locator,
|
|
78
|
+
value: observation.secret
|
|
79
|
+
? { kind: "secret", name: secretNameFor(observation.secretName) }
|
|
80
|
+
: { kind: "literal", value: observation.value ?? "" },
|
|
81
|
+
};
|
|
82
|
+
if (this.lastFill?.key === observation.key) {
|
|
83
|
+
this.lastStep = step;
|
|
84
|
+
return [{ op: "replace", index: this.lastFill.index, step }];
|
|
85
|
+
}
|
|
86
|
+
this.lastFill = { key: observation.key, index: this.next };
|
|
87
|
+
return [this.insert(step)];
|
|
88
|
+
}
|
|
89
|
+
/** A top-level navigation started */
|
|
90
|
+
navigated(url, at) {
|
|
91
|
+
this.lastFill = undefined;
|
|
92
|
+
if (at - this.lastAction <= this.window) {
|
|
93
|
+
this.lastAction = -Infinity;
|
|
94
|
+
return this.lastStep?.kind === "waitForNetworkIdle"
|
|
95
|
+
? []
|
|
96
|
+
: [this.insert({ kind: "waitForNetworkIdle" })];
|
|
97
|
+
}
|
|
98
|
+
return [this.insert({ kind: "goto", url: this.relative(url) })];
|
|
99
|
+
}
|
|
100
|
+
/** The page the recording starts on, written as a `goto` */
|
|
101
|
+
initialGoto(url) {
|
|
102
|
+
return [this.insert({ kind: "goto", url: this.relative(url) })];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type Page from "../browser/page.js";
|
|
2
|
+
import type { ElementHandle } from "../script/element-handle.js";
|
|
3
|
+
import type { LocatorCall, LocatorSpec } from "../steps/model.js";
|
|
4
|
+
/**
|
|
5
|
+
* Picks the locators for `target` from `candidates` (most stable first). Only a candidate that matches
|
|
6
|
+
* exactly one element, the target, is used: the first becomes the primary, up to `maxFallbacks` more
|
|
7
|
+
* become its fallbacks. Returns undefined when no candidate qualifies.
|
|
8
|
+
*/
|
|
9
|
+
export declare function rankLocator(page: Page, target: ElementHandle, candidates: readonly LocatorCall[], maxFallbacks?: number): Promise<LocatorSpec | undefined>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { locatorFromSpec } from "../steps/build.js";
|
|
2
|
+
/**
|
|
3
|
+
* Picks the locators for `target` from `candidates` (most stable first). Only a candidate that matches
|
|
4
|
+
* exactly one element, the target, is used: the first becomes the primary, up to `maxFallbacks` more
|
|
5
|
+
* become its fallbacks. Returns undefined when no candidate qualifies.
|
|
6
|
+
*/
|
|
7
|
+
export async function rankLocator(page, target, candidates, maxFallbacks = 2) {
|
|
8
|
+
const verified = [];
|
|
9
|
+
for (const candidate of candidates) {
|
|
10
|
+
if (verified.length > maxFallbacks)
|
|
11
|
+
break;
|
|
12
|
+
const found = await locatorFromSpec(page, {
|
|
13
|
+
chain: [candidate],
|
|
14
|
+
fallbacks: [],
|
|
15
|
+
}).elements();
|
|
16
|
+
if (found.length === 1 && found[0].sharedId === target.sharedId)
|
|
17
|
+
verified.push(candidate);
|
|
18
|
+
}
|
|
19
|
+
const [primary, ...fallbacks] = verified;
|
|
20
|
+
return (primary && { chain: [primary], fallbacks: fallbacks.map((call) => [call]) });
|
|
21
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type Page from "../browser/page.js";
|
|
2
|
+
import type { BiDiConnector } from "../transport/bidi-connection.js";
|
|
3
|
+
import { type RecorderEvent } from "./normalise.js";
|
|
4
|
+
export type { RecorderEvent } from "./normalise.js";
|
|
5
|
+
export interface RecorderOptions {
|
|
6
|
+
/** Called for every step the tester produces, in order. Steps get indexes from `at` on */
|
|
7
|
+
onEvent: (event: RecorderEvent) => void;
|
|
8
|
+
/**
|
|
9
|
+
* Index the first recorded step gets: record from the middle of a test by passing the number of steps
|
|
10
|
+
* before it. The page must already be in the state those steps lead to; replaying them is up to the caller.
|
|
11
|
+
* @default 0
|
|
12
|
+
*/
|
|
13
|
+
at?: number;
|
|
14
|
+
/** Write the page the recording starts on as a `goto` step. Pass false when `at` > 0. @default true */
|
|
15
|
+
initialGoto?: boolean;
|
|
16
|
+
/** URLs inside it are written relative to it, as `page.goto` resolves them */
|
|
17
|
+
baseURL?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
|
|
21
|
+
* typing and Alt+click assertions over a `script.message` channel; each element is given locators that
|
|
22
|
+
* were checked against the page; the normaliser turns the stream into steps.
|
|
23
|
+
*/
|
|
24
|
+
export declare class Recorder {
|
|
25
|
+
private readonly page;
|
|
26
|
+
private readonly connector;
|
|
27
|
+
private readonly context;
|
|
28
|
+
private readonly normaliser;
|
|
29
|
+
private readonly options;
|
|
30
|
+
private readonly channel;
|
|
31
|
+
private readonly queue;
|
|
32
|
+
private readonly known;
|
|
33
|
+
private stopped;
|
|
34
|
+
private constructor();
|
|
35
|
+
static start(target: {
|
|
36
|
+
page: Page;
|
|
37
|
+
connector: BiDiConnector;
|
|
38
|
+
context: string;
|
|
39
|
+
}, options: RecorderOptions): Promise<Recorder>;
|
|
40
|
+
private readonly onMessage;
|
|
41
|
+
private readonly onNavigation;
|
|
42
|
+
private subscription;
|
|
43
|
+
private preload;
|
|
44
|
+
private begin;
|
|
45
|
+
private enqueue;
|
|
46
|
+
private emit;
|
|
47
|
+
private locatorFor;
|
|
48
|
+
private handle;
|
|
49
|
+
private release;
|
|
50
|
+
/** Stops recording. Events already received are still processed and reported first */
|
|
51
|
+
stop(): Promise<void>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import logger from "../logger/index.js";
|
|
3
|
+
import { addPreload, runInLoadedContexts } from "../script/preload.js";
|
|
4
|
+
import { ChannelArg, fromRemoteValue } from "../script/serialize.js";
|
|
5
|
+
import { ElementHandle } from "../script/element-handle.js";
|
|
6
|
+
import { CAPTURE_OFF, CAPTURE_SOURCE } from "./capture.js";
|
|
7
|
+
import { Normaliser, } from "./normalise.js";
|
|
8
|
+
import { rankLocator } from "./ranking.js";
|
|
9
|
+
/**
|
|
10
|
+
* Watches the tester use the page and reports steps as they happen. A preload script reports clicks,
|
|
11
|
+
* typing and Alt+click assertions over a `script.message` channel; each element is given locators that
|
|
12
|
+
* were checked against the page; the normaliser turns the stream into steps.
|
|
13
|
+
*/
|
|
14
|
+
export class Recorder {
|
|
15
|
+
constructor(page, connector, context, normaliser, options, channel) {
|
|
16
|
+
this.page = page;
|
|
17
|
+
this.connector = connector;
|
|
18
|
+
this.context = context;
|
|
19
|
+
this.normaliser = normaliser;
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.channel = channel;
|
|
22
|
+
this.queue = { tail: Promise.resolve() };
|
|
23
|
+
this.known = new Map();
|
|
24
|
+
this.stopped = false;
|
|
25
|
+
this.onMessage = (params) => {
|
|
26
|
+
if (params.channel !== this.channel ||
|
|
27
|
+
params.source.context !== this.context)
|
|
28
|
+
return;
|
|
29
|
+
const message = fromRemoteValue(params.data);
|
|
30
|
+
this.enqueue(async () => this.handle(message));
|
|
31
|
+
};
|
|
32
|
+
this.onNavigation = (params) => {
|
|
33
|
+
if (params.context !== this.context || params.url === "about:blank")
|
|
34
|
+
return;
|
|
35
|
+
this.known.clear();
|
|
36
|
+
this.enqueue(async () => this.emit(this.normaliser.navigated(params.url, Date.now())));
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
static async start(target, options) {
|
|
40
|
+
const at = options.at ?? 0;
|
|
41
|
+
const normaliser = new Normaliser({
|
|
42
|
+
at,
|
|
43
|
+
...(options.baseURL !== undefined && { baseURL: options.baseURL }),
|
|
44
|
+
});
|
|
45
|
+
const recorder = new Recorder(target.page, target.connector, target.context, normaliser, options, `samurai-recorder-${randomUUID()}`);
|
|
46
|
+
await recorder.begin(options.initialGoto ?? true);
|
|
47
|
+
return recorder;
|
|
48
|
+
}
|
|
49
|
+
async begin(initialGoto) {
|
|
50
|
+
this.connector.onEvent("script.message", this.onMessage);
|
|
51
|
+
this.connector.onEvent("browsingContext.navigationStarted", this.onNavigation);
|
|
52
|
+
try {
|
|
53
|
+
this.subscription = await this.connector.subscribe([
|
|
54
|
+
"script.message",
|
|
55
|
+
"browsingContext.navigationStarted",
|
|
56
|
+
]);
|
|
57
|
+
if (initialGoto) {
|
|
58
|
+
const url = await this.page.url();
|
|
59
|
+
if (url !== "about:blank")
|
|
60
|
+
this.emit(this.normaliser.initialGoto(url));
|
|
61
|
+
}
|
|
62
|
+
this.preload = await addPreload(this.connector, {
|
|
63
|
+
source: CAPTURE_SOURCE,
|
|
64
|
+
contexts: [this.context],
|
|
65
|
+
arguments: [new ChannelArg(this.channel)],
|
|
66
|
+
onRunError: "log",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
await this.release();
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
enqueue(work) {
|
|
75
|
+
this.queue.tail = this.queue.tail.then(work).catch((err) => {
|
|
76
|
+
logger.warn("Recorder could not process an event", { err });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
emit(events) {
|
|
80
|
+
if (this.stopped)
|
|
81
|
+
return;
|
|
82
|
+
for (const event of events)
|
|
83
|
+
this.options.onEvent(event);
|
|
84
|
+
}
|
|
85
|
+
async locatorFor(message) {
|
|
86
|
+
const { sharedId } = message.target;
|
|
87
|
+
if (this.known.has(sharedId))
|
|
88
|
+
return this.known.get(sharedId);
|
|
89
|
+
const spec = await rankLocator(this.page, message.target, message.candidates);
|
|
90
|
+
this.known.set(sharedId, spec);
|
|
91
|
+
return spec;
|
|
92
|
+
}
|
|
93
|
+
async handle(message) {
|
|
94
|
+
const locator = await this.locatorFor(message);
|
|
95
|
+
if (!locator) {
|
|
96
|
+
logger.warn("Recorder found no locator that matches only the element the tester used; the %s was skipped", message.type);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const key = message.target.sharedId;
|
|
100
|
+
const observation = message.type === "click"
|
|
101
|
+
? { type: "click", key, locator, textEntry: message.textEntry === true }
|
|
102
|
+
: message.type === "assert"
|
|
103
|
+
? {
|
|
104
|
+
type: "assert",
|
|
105
|
+
key,
|
|
106
|
+
locator,
|
|
107
|
+
text: message.text ?? "",
|
|
108
|
+
...(message.value !== undefined && { value: message.value }),
|
|
109
|
+
}
|
|
110
|
+
: {
|
|
111
|
+
type: "input",
|
|
112
|
+
key,
|
|
113
|
+
locator,
|
|
114
|
+
...(message.value !== undefined && { value: message.value }),
|
|
115
|
+
...(message.secret && { secret: true }),
|
|
116
|
+
...(message.secretName !== undefined && {
|
|
117
|
+
secretName: message.secretName,
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
this.emit(this.normaliser.observe(observation, Date.now()));
|
|
121
|
+
}
|
|
122
|
+
async release() {
|
|
123
|
+
this.connector.offEvent("script.message", this.onMessage);
|
|
124
|
+
this.connector.offEvent("browsingContext.navigationStarted", this.onNavigation);
|
|
125
|
+
await Promise.allSettled([
|
|
126
|
+
this.subscription?.unsubscribe(),
|
|
127
|
+
this.preload?.dispose(),
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
/** Stops recording. Events already received are still processed and reported first */
|
|
131
|
+
async stop() {
|
|
132
|
+
if (this.stopped)
|
|
133
|
+
return;
|
|
134
|
+
await runInLoadedContexts(this.connector, {
|
|
135
|
+
source: CAPTURE_OFF,
|
|
136
|
+
contexts: [this.context],
|
|
137
|
+
onRunError: "log",
|
|
138
|
+
}).catch(() => undefined);
|
|
139
|
+
await this.queue.tail;
|
|
140
|
+
this.stopped = true;
|
|
141
|
+
await this.release();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Browser } from "../browser/browser.js";
|
|
2
|
+
import type Page from "../browser/page.js";
|
|
3
|
+
import { type ProjectOptions } from "../runner/project.js";
|
|
4
|
+
import type { RecorderEvent } from "./normalise.js";
|
|
5
|
+
export interface RecordSpecOptions extends ProjectOptions {
|
|
6
|
+
/** The spec file, absolute or relative to the project folder. Created when `create` is given and it doesn't exist */
|
|
7
|
+
file: string;
|
|
8
|
+
/** The test to record into: its full name (or a part that is unique), or its index in the file. Optional when the file has one test */
|
|
9
|
+
test?: string | number;
|
|
10
|
+
/** Add a test with this title to the file (and create the file) instead of recording into an existing one */
|
|
11
|
+
create?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Index the first recorded step gets. @default after the last step of the test.
|
|
14
|
+
* The browser starts on `url` (or the base URL), not where steps `0…at-1` would leave it
|
|
15
|
+
*/
|
|
16
|
+
at?: number;
|
|
17
|
+
/** The page to start on. @default the environment's base URL; without one the browser starts blank */
|
|
18
|
+
url?: string;
|
|
19
|
+
/** The browser's debugging port. @default 9223 */
|
|
20
|
+
port?: number;
|
|
21
|
+
/** Aborting ends the recording, like closing the browser window */
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
/** Called after each step is written to the file, with the file's new content */
|
|
24
|
+
onEvent?: (event: RecorderEvent, source: string) => void;
|
|
25
|
+
/** Called once the browser is open and recording */
|
|
26
|
+
onStarted?: (context: {
|
|
27
|
+
page: Page;
|
|
28
|
+
browser: Browser;
|
|
29
|
+
}) => void | Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface RecordResult {
|
|
32
|
+
file: string;
|
|
33
|
+
/** Full name of the test the steps went into */
|
|
34
|
+
test: string;
|
|
35
|
+
events: RecorderEvent[];
|
|
36
|
+
/** Names the recorded steps read with `secrets.<NAME>`: they must be set before the test can run */
|
|
37
|
+
secrets: string[];
|
|
38
|
+
}
|
|
39
|
+
/** The index of the test `selector` names (see `RecordSpecOptions.test`) */
|
|
40
|
+
export declare function pickTest(tests: readonly {
|
|
41
|
+
name: string;
|
|
42
|
+
}[], selector: string | number | undefined): number;
|
|
43
|
+
/**
|
|
44
|
+
* Opens a browser window and records what the person does into a spec file, step by step, until `signal`
|
|
45
|
+
* aborts or the window is closed. Every step is written to the file as it happens, as a minimal edit.
|
|
46
|
+
* @example
|
|
47
|
+
* await recordSpec({ file: "tests/login.spec.ts", create: "signs in", environment: "staging" });
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordSpec(options: RecordSpecOptions): Promise<RecordResult>;
|