effect-browser 0.2.0-beta.8 → 0.3.0-beta.5
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 +705 -1166
- package/dist/Browser-CM65Uo7f.mjs +437 -0
- package/dist/Browser-CM65Uo7f.mjs.map +1 -0
- package/dist/Browser.d.mts +115 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +202 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +295 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-DVP2IJWm.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-BERmf_gM.d.mts +117 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +123 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-D4281dIi.mjs +618 -0
- package/dist/Page-D4281dIi.mjs.map +1 -0
- package/dist/Page-DYTdVhYa.d.mts +593 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-CNNOU1Tx.d.mts +112 -0
- package/dist/Plan-Dtrb-8im.mjs +311 -0
- package/dist/Plan-Dtrb-8im.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +54 -0
- package/dist/Stage.mjs +133 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-BtEG93gR.mjs +420 -0
- package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
- package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-DAYjATgC.mjs +137 -0
- package/dist/context-DAYjATgC.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-Banfm_zc.mjs +5097 -0
- package/dist/page-Banfm_zc.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-CcL0K7S0.mjs +262 -0
- package/dist/url-CcL0K7S0.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +625 -0
- package/src/BrowserError.ts +225 -0
- package/src/BrowserEvent.ts +294 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +130 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +520 -0
- package/src/Plan.ts +350 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +223 -0
- package/src/Supervisor.ts +353 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +607 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +183 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +322 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +159 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +386 -0
- package/src/internal/page/context.ts +302 -0
- package/src/internal/page/lane.ts +301 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +137 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +778 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/pictures.ts +520 -0
- package/src/internal/pictures/transport.ts +242 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +154 -0
- package/src/internal/reading/ready.ts +105 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +265 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +93 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-n-bKgBNN.d.mts +0 -372
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-CFe9uUTv.d.mts +0 -148
- package/dist/BrowserRuntime-HJ1yYlFS.mjs +0 -6741
- package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-CaaPNeed.mjs +0 -76
- package/dist/Images-CaaPNeed.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-D8qQ48Ic.mjs +0 -512
- package/dist/Plan-D8qQ48Ic.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-Bs61Eal8.mjs +0 -5601
- package/dist/PublicSession-Bs61Eal8.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: a control's states and value as the outline shows them, and as a value; the elements a CSS selector scopes a
|
|
3
|
+
* read to; and a condition `Page.waitFor` waits for. See `names.inpage.ts` for what a page-side part
|
|
4
|
+
* may use.
|
|
5
|
+
*/
|
|
6
|
+
import type { Control } from "../../Snapshot.ts";
|
|
7
|
+
import type { Names } from "./names.inpage.ts";
|
|
8
|
+
import type { Subjects } from "./subjects.inpage.ts";
|
|
9
|
+
import type { Texts } from "./text.inpage.ts";
|
|
10
|
+
import type { Walk } from "./walk.inpage.ts";
|
|
11
|
+
|
|
12
|
+
/** What `waitUntil` waits for, as `Page.waitFor` asks it. */
|
|
13
|
+
export interface ConditionRequest {
|
|
14
|
+
readonly selector: string | null;
|
|
15
|
+
readonly text: string | null;
|
|
16
|
+
readonly state: "visible" | "hidden" | "enabled";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether the condition held by the deadline, or why the selector is not CSS. */
|
|
20
|
+
export type ConditionResult = { readonly met: boolean } | { readonly invalid: string };
|
|
21
|
+
|
|
22
|
+
export const controls = (names: Names, walked: Walk, subjects: Subjects, texts: Texts) => {
|
|
23
|
+
const { clean, isDisabled, isHtml, isInput, isSelect, isTextArea, refFor, textOf } = names;
|
|
24
|
+
const { visible } = walked;
|
|
25
|
+
const { stateOf } = subjects;
|
|
26
|
+
const { shown } = texts;
|
|
27
|
+
|
|
28
|
+
// The states that hold, in the order `stateOf` gives them, after a heading's level.
|
|
29
|
+
const states = (element: Element, role: string | null): string => {
|
|
30
|
+
const { level, ...flags } = stateOf(element, role);
|
|
31
|
+
const held = Object.entries(flags).flatMap(([name, value]) => (value === true ? [name] : []));
|
|
32
|
+
|
|
33
|
+
return [...(level === undefined ? [] : [`level=${level}`]), ...held]
|
|
34
|
+
.map((name) => ` [${name}]`)
|
|
35
|
+
.join("");
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const valueOf = (element: Element): string => {
|
|
39
|
+
if (isSelect(element)) {
|
|
40
|
+
const selected = Array.from(element.selectedOptions, (option) => clean(option.text, 40));
|
|
41
|
+
|
|
42
|
+
const options = Array.from(element.options)
|
|
43
|
+
.slice(0, 12)
|
|
44
|
+
.map((option) => clean(option.text, 40));
|
|
45
|
+
|
|
46
|
+
const more = element.options.length > 12 ? ` +${element.options.length - 12} more` : "";
|
|
47
|
+
|
|
48
|
+
return ` value=${JSON.stringify(selected.join(", "))} options=${JSON.stringify(options.join(" | ") + more)}`;
|
|
49
|
+
}
|
|
50
|
+
// The model sees what it typed, but never what a secret field holds.
|
|
51
|
+
const value = isInput(element) || isTextArea(element) ? shown(element, true) : undefined;
|
|
52
|
+
|
|
53
|
+
return value === undefined || value === "" ? "" : ` value=${JSON.stringify(clean(value, 80))}`;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Inputs that take no typed text.
|
|
57
|
+
const untyped = ["button", "reset", "submit", "image", "checkbox", "radio", "file", "range"];
|
|
58
|
+
|
|
59
|
+
/** A control as a value, as its outline line shows it. */
|
|
60
|
+
const controlOf = (element: Element, role: string | null, kind: string, name: string) => {
|
|
61
|
+
const { disabled, checked } = stateOf(element, role);
|
|
62
|
+
const select = isSelect(element) ? element : undefined;
|
|
63
|
+
|
|
64
|
+
const value =
|
|
65
|
+
select !== undefined
|
|
66
|
+
? Array.from(select.selectedOptions, (option) => clean(option.text, 80)).join(", ")
|
|
67
|
+
: isInput(element) || isTextArea(element)
|
|
68
|
+
? clean(shown(element, true), 200)
|
|
69
|
+
: "";
|
|
70
|
+
|
|
71
|
+
const editable =
|
|
72
|
+
isTextArea(element) ||
|
|
73
|
+
(isInput(element) && !untyped.includes(element.type)) ||
|
|
74
|
+
(isHtml(element) && element.isContentEditable);
|
|
75
|
+
|
|
76
|
+
const control: typeof Control.Encoded = {
|
|
77
|
+
ref: refFor(element),
|
|
78
|
+
kind,
|
|
79
|
+
name,
|
|
80
|
+
value,
|
|
81
|
+
...(select === undefined
|
|
82
|
+
? {}
|
|
83
|
+
: {
|
|
84
|
+
options: Array.from(select.options)
|
|
85
|
+
.slice(0, 64)
|
|
86
|
+
.map((option) => clean(option.text, 80)),
|
|
87
|
+
optionCount: select.options.length,
|
|
88
|
+
}),
|
|
89
|
+
...(disabled ? { disabled } : {}),
|
|
90
|
+
...(checked === undefined ? {} : { checked }),
|
|
91
|
+
...(editable ? { editable } : {}),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return control;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The elements a selector matches, each read whole, so one inside another that matched is left
|
|
99
|
+
* to it; or why the selector is not CSS.
|
|
100
|
+
*/
|
|
101
|
+
const rootsOf = (selector: string): ReadonlyArray<Element> | { readonly invalid: string } => {
|
|
102
|
+
let matched: ReadonlyArray<Element>;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
matched = Array.from(document.querySelectorAll(selector));
|
|
106
|
+
} catch {
|
|
107
|
+
return { invalid: `${JSON.stringify(selector)} is not a CSS selector` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return matched.filter(
|
|
111
|
+
(root) => !matched.some((other) => other !== root && other.contains(root)),
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Whether the condition holds now: what the selector matches, or the body, and shows, with the
|
|
116
|
+
// text when one is asked, case and all. A document has no body until its parser reaches one.
|
|
117
|
+
const holds = (request: ConditionRequest): ConditionResult => {
|
|
118
|
+
const roots =
|
|
119
|
+
request.selector === null ? [document.body].filter(Boolean) : rootsOf(request.selector);
|
|
120
|
+
|
|
121
|
+
if ("invalid" in roots) return roots;
|
|
122
|
+
const text = request.text === null ? null : clean(request.text, Infinity);
|
|
123
|
+
|
|
124
|
+
const showing = roots.filter(
|
|
125
|
+
(element) =>
|
|
126
|
+
visible(element) && (text === null || clean(textOf(element), Infinity).includes(text)),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
met:
|
|
131
|
+
request.state === "hidden"
|
|
132
|
+
? showing.length === 0
|
|
133
|
+
: request.state === "enabled"
|
|
134
|
+
? showing.some((element) => !isDisabled(element))
|
|
135
|
+
: showing.length > 0,
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Looks every 100 ms until the condition holds or the deadline passes.
|
|
140
|
+
const waitUntil = async (request: ConditionRequest, millis: number) => {
|
|
141
|
+
const until = performance.now() + millis;
|
|
142
|
+
|
|
143
|
+
for (;;) {
|
|
144
|
+
const result = holds(request);
|
|
145
|
+
const left = until - performance.now();
|
|
146
|
+
|
|
147
|
+
if ("invalid" in result || result.met || left <= 0) return result;
|
|
148
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
149
|
+
|
|
150
|
+
setTimeout(resolve, Math.min(100, left));
|
|
151
|
+
await promise;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return { controlOf, rootsOf, states, valueOf, waitUntil };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export type Controls = ReturnType<typeof controls>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: the wait an expectation takes on what `find` matches, and the mark that stands for
|
|
3
|
+
* what the matches show. See `names.inpage.ts` for what a page-side part may use.
|
|
4
|
+
*/
|
|
5
|
+
import type { FindRequest } from "./match.inpage.ts";
|
|
6
|
+
import type { Names } from "./names.inpage.ts";
|
|
7
|
+
import type { Subjects } from "./subjects.inpage.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What an expectation waits for of a query's matches: some, none, or a mark other than one `until`
|
|
11
|
+
* gave before.
|
|
12
|
+
*/
|
|
13
|
+
export type Want = "some" | "none" | { readonly other: string };
|
|
14
|
+
|
|
15
|
+
export const expectations = (names: Names, subjected: Subjects) => {
|
|
16
|
+
const { isInput, isSelect, isTextArea, roleOf, textOf } = names;
|
|
17
|
+
const { elementsOf, stateOf } = subjected;
|
|
18
|
+
|
|
19
|
+
// What matched elements show, their text, values and states but focus, as a mark that carries
|
|
20
|
+
// none of it: a count and FNV-1a over each in turn.
|
|
21
|
+
const markOf = (elements: ReadonlyArray<Element>) => {
|
|
22
|
+
let hash = 0x811c9dc5;
|
|
23
|
+
|
|
24
|
+
for (const element of elements) {
|
|
25
|
+
const { focused: _focused, ...state } = stateOf(element, roleOf(element));
|
|
26
|
+
|
|
27
|
+
const value =
|
|
28
|
+
isInput(element) || isSelect(element) || isTextArea(element) ? element.value : "";
|
|
29
|
+
|
|
30
|
+
const shown = `${textOf(element)}\u0000${value}\u0000${JSON.stringify(state)}\u0001`;
|
|
31
|
+
|
|
32
|
+
for (let index = 0; index < shown.length; index++)
|
|
33
|
+
hash = Math.imul(hash ^ shown.charCodeAt(index), 0x01000193);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return `${elements.length}:${(hash >>> 0).toString(16)}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What `find` matches, looked at every 100 ms until `want` holds of it or `millis` have passed,
|
|
41
|
+
* with its mark then. It gives no refs.
|
|
42
|
+
*/
|
|
43
|
+
const until = async (request: FindRequest, want: Want, millis: number) => {
|
|
44
|
+
const deadline = performance.now() + millis;
|
|
45
|
+
|
|
46
|
+
for (;;) {
|
|
47
|
+
const elements = elementsOf(request);
|
|
48
|
+
const mark = markOf(elements);
|
|
49
|
+
|
|
50
|
+
const met =
|
|
51
|
+
want === "some"
|
|
52
|
+
? elements.length > 0
|
|
53
|
+
: want === "none"
|
|
54
|
+
? elements.length === 0
|
|
55
|
+
: mark !== want.other;
|
|
56
|
+
|
|
57
|
+
const left = deadline - performance.now();
|
|
58
|
+
|
|
59
|
+
if (met || left <= 0) return { met, mark };
|
|
60
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
61
|
+
|
|
62
|
+
setTimeout(resolve, Math.min(100, left));
|
|
63
|
+
await promise;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return { until };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type Expectations = ReturnType<typeof expectations>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: the rules `find` matches by. It touches no DOM, so it also runs outside a page.
|
|
3
|
+
*
|
|
4
|
+
* - A role matches when it is the same token, ignoring case and the space around it.
|
|
5
|
+
* - A name given as a string matches when the two read the same once their spaces are collapsed,
|
|
6
|
+
* their case folded and their characters given one Unicode form; a pattern matches when it
|
|
7
|
+
* finds itself in the name.
|
|
8
|
+
* - Text matches when it appears, read the same way, in the element's text.
|
|
9
|
+
* - `near` matches when its words appear, whole and in order, in any of the element's context:
|
|
10
|
+
* its row, column, label or heading. Replay chooses among candidates by the same rule.
|
|
11
|
+
*/
|
|
12
|
+
import type { SubjectContext } from "../../BrowserEvent.ts";
|
|
13
|
+
|
|
14
|
+
/** A string, or a pattern sent to the page as its source and flags. */
|
|
15
|
+
export type Wanted = string | { readonly source: string; readonly flags: string };
|
|
16
|
+
|
|
17
|
+
export interface FindRequest {
|
|
18
|
+
readonly role: string | null;
|
|
19
|
+
readonly name: Wanted | null;
|
|
20
|
+
readonly text: Wanted | null;
|
|
21
|
+
readonly near: string | null;
|
|
22
|
+
readonly at: { readonly x: number; readonly y: number } | null;
|
|
23
|
+
readonly scope: "viewport" | "document";
|
|
24
|
+
readonly firstRef: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What a query is matched against, read only when a rule needs it. */
|
|
28
|
+
export interface Candidate {
|
|
29
|
+
readonly role: string | null;
|
|
30
|
+
readonly name: () => string;
|
|
31
|
+
readonly text: () => string;
|
|
32
|
+
readonly context: () => SubjectContext;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const match = () => {
|
|
36
|
+
/**
|
|
37
|
+
* How names, text and context compare: one Unicode form, without invisible format characters,
|
|
38
|
+
* spaces collapsed, and case folded upper first, so that "ß" and "SS" read the same.
|
|
39
|
+
*/
|
|
40
|
+
const normalize = (value: string): string =>
|
|
41
|
+
value
|
|
42
|
+
.normalize("NFKC")
|
|
43
|
+
.replace(/\p{Cf}/gu, "")
|
|
44
|
+
.replace(/\s+/g, " ")
|
|
45
|
+
.trim()
|
|
46
|
+
.toUpperCase()
|
|
47
|
+
.toLowerCase();
|
|
48
|
+
|
|
49
|
+
/** A string that reads the same, or, unless `whole`, appears within; or a pattern found within. */
|
|
50
|
+
const rule = (wanted: Wanted, whole: boolean) => {
|
|
51
|
+
if (typeof wanted !== "string") {
|
|
52
|
+
// `g` and `y` keep a position from one test to the next, so one pattern would answer
|
|
53
|
+
// differently for each element. A pattern is tried on the text as the page shows it.
|
|
54
|
+
const found = new RegExp(wanted.source, wanted.flags.replace(/[gy]/g, ""));
|
|
55
|
+
|
|
56
|
+
return (value: string) => found.test(value.replace(/\s+/g, " ").trim());
|
|
57
|
+
}
|
|
58
|
+
const target = normalize(wanted);
|
|
59
|
+
|
|
60
|
+
return whole
|
|
61
|
+
? (value: string) => normalize(value) === target
|
|
62
|
+
: (value: string) => normalize(value).includes(target);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Whether a value's words appear, whole and in order, among a text's: "BTC" not in "WBTC". */
|
|
66
|
+
const holds = (text: string, value: string) =>
|
|
67
|
+
` ${normalize(text)} `.includes(` ${normalize(value)} `);
|
|
68
|
+
|
|
69
|
+
/** Whether a candidate meets every rule a query gives; a rule left out always holds. */
|
|
70
|
+
const compile = (request: Pick<FindRequest, "role" | "name" | "text" | "near">) => {
|
|
71
|
+
const role = request.role === null ? null : normalize(request.role);
|
|
72
|
+
const name = request.name === null ? null : rule(request.name, true);
|
|
73
|
+
const text = request.text === null ? null : rule(request.text, false);
|
|
74
|
+
const { near } = request;
|
|
75
|
+
|
|
76
|
+
return (candidate: Candidate): boolean => {
|
|
77
|
+
if (role !== null && normalize(candidate.role ?? "") !== role) return false;
|
|
78
|
+
if (text !== null && !text(candidate.text())) return false;
|
|
79
|
+
if (name !== null && !name(candidate.name())) return false;
|
|
80
|
+
if (near === null) return true;
|
|
81
|
+
const { row, column, label, heading } = candidate.context();
|
|
82
|
+
|
|
83
|
+
return [row, column, label, heading].some(
|
|
84
|
+
(value) => value !== undefined && holds(value, near),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return { compile, holds, normalize };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type Match = ReturnType<typeof match>;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: the foundation the other parts build on. It gives elements refs and reads the
|
|
3
|
+
* roles, names and states that the outline and the input checks report.
|
|
4
|
+
*
|
|
5
|
+
* Every `*.inpage.ts` part runs inside the page. The page receives it as source text, so it must
|
|
6
|
+
* stay self-contained: it uses only its parameters, the DOM and what it defines itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const names = () => {
|
|
10
|
+
const byElement = new WeakMap<Element, string>();
|
|
11
|
+
const byRef = new Map<string, WeakRef<Element>>();
|
|
12
|
+
const refs = { next: 1 };
|
|
13
|
+
|
|
14
|
+
const interactiveRoles = new Set(
|
|
15
|
+
"button link checkbox radio tab menuitem menuitemcheckbox menuitemradio option switch slider combobox listbox textbox searchbox spinbutton treeitem".split(
|
|
16
|
+
" ",
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const containers: Record<string, string> = {
|
|
21
|
+
HEADER: "banner",
|
|
22
|
+
NAV: "navigation",
|
|
23
|
+
MAIN: "main",
|
|
24
|
+
ASIDE: "complementary",
|
|
25
|
+
FOOTER: "contentinfo",
|
|
26
|
+
FORM: "form",
|
|
27
|
+
DIALOG: "dialog",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Elements of a same-origin frame come from another realm, so tag names decide, not instanceof.
|
|
31
|
+
const isInput = (element: Element): element is HTMLInputElement => element.tagName === "INPUT";
|
|
32
|
+
|
|
33
|
+
const isTextArea = (element: Element): element is HTMLTextAreaElement =>
|
|
34
|
+
element.tagName === "TEXTAREA";
|
|
35
|
+
|
|
36
|
+
const isSelect = (element: Element): element is HTMLSelectElement => element.tagName === "SELECT";
|
|
37
|
+
const isFrame = (element: Element): element is HTMLIFrameElement => element.tagName === "IFRAME";
|
|
38
|
+
const isHtml = (element: Element): element is HTMLElement => "innerText" in element;
|
|
39
|
+
|
|
40
|
+
const isButton = (element: Element): element is HTMLButtonElement => element.tagName === "BUTTON";
|
|
41
|
+
|
|
42
|
+
const isLabel = (element: Element): element is HTMLLabelElement => element.tagName === "LABEL";
|
|
43
|
+
|
|
44
|
+
const textOf = (element: Element): string =>
|
|
45
|
+
isHtml(element) ? element.innerText : (element.textContent ?? "");
|
|
46
|
+
|
|
47
|
+
const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
|
|
48
|
+
const isDocument = (node: Node): node is Document => node.nodeType === Node.DOCUMENT_NODE;
|
|
49
|
+
|
|
50
|
+
// Controls whose text is a value or a choice, not words of a label around them.
|
|
51
|
+
const valueRoles = new Set(
|
|
52
|
+
"combobox listbox option textbox searchbox spinbutton slider".split(" "),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A label's own words, without the options or values of the controls inside it, native or
|
|
57
|
+
* scripted, such as the custom select Browserbase puts in place of a native one.
|
|
58
|
+
*/
|
|
59
|
+
const labelText = (label: Element): string => {
|
|
60
|
+
let text = "";
|
|
61
|
+
|
|
62
|
+
const collect = (node: Node): void => {
|
|
63
|
+
for (const child of Array.from(node.childNodes)) {
|
|
64
|
+
if (child.nodeType === Node.TEXT_NODE) text += child.textContent ?? "";
|
|
65
|
+
else if (
|
|
66
|
+
isElement(child) &&
|
|
67
|
+
!["SELECT", "TEXTAREA", "INPUT", "SCRIPT", "STYLE"].includes(child.tagName) &&
|
|
68
|
+
!valueRoles.has(roleOf(child) ?? "")
|
|
69
|
+
)
|
|
70
|
+
collect(child);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
collect(label);
|
|
75
|
+
|
|
76
|
+
return text;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const clean = (value: string | null | undefined, max = 160): string => {
|
|
80
|
+
const text = (value ?? "").replace(/\s+/g, " ").trim();
|
|
81
|
+
|
|
82
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const roleOf = (element: Element): string | null => {
|
|
86
|
+
const explicit = element.getAttribute("role");
|
|
87
|
+
|
|
88
|
+
if (explicit !== null && explicit.trim() !== "") return explicit.trim().split(/\s+/)[0] ?? null;
|
|
89
|
+
const tag = element.tagName;
|
|
90
|
+
|
|
91
|
+
if (tag === "A") return element.hasAttribute("href") ? "link" : null;
|
|
92
|
+
if (tag === "BUTTON" || tag === "SUMMARY") return "button";
|
|
93
|
+
if (isSelect(element)) return element.multiple ? "listbox" : "combobox";
|
|
94
|
+
if (tag === "TEXTAREA") return "textbox";
|
|
95
|
+
if (isInput(element)) {
|
|
96
|
+
const type = element.type;
|
|
97
|
+
|
|
98
|
+
if (type === "hidden") return null;
|
|
99
|
+
if (type === "checkbox" || type === "radio") return type;
|
|
100
|
+
if (type === "range") return "slider";
|
|
101
|
+
if (type === "number") return "spinbutton";
|
|
102
|
+
if (type === "search") return "searchbox";
|
|
103
|
+
if (type === "button" || type === "submit" || type === "reset" || type === "image")
|
|
104
|
+
return "button";
|
|
105
|
+
|
|
106
|
+
return "textbox";
|
|
107
|
+
}
|
|
108
|
+
if (/^H[1-6]$/.test(tag)) return "heading";
|
|
109
|
+
if (tag === "IMG") return "img";
|
|
110
|
+
if (tag === "CANVAS") return "canvas";
|
|
111
|
+
if (tag === "IFRAME") return "iframe";
|
|
112
|
+
if (element.getAttribute("contenteditable") === "true") return "textbox";
|
|
113
|
+
|
|
114
|
+
return containers[tag] ?? null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const nameOf = (element: Element, role: string | null): string => {
|
|
118
|
+
const label = element.getAttribute("aria-label");
|
|
119
|
+
|
|
120
|
+
if (label !== null && clean(label) !== "") return clean(label);
|
|
121
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
122
|
+
|
|
123
|
+
if (labelledBy !== null) {
|
|
124
|
+
const text = labelledBy
|
|
125
|
+
.split(/\s+/)
|
|
126
|
+
.map((id) => {
|
|
127
|
+
const labelling = element.ownerDocument.getElementById(id);
|
|
128
|
+
|
|
129
|
+
return labelling === null ? "" : labelText(labelling);
|
|
130
|
+
})
|
|
131
|
+
.join(" ");
|
|
132
|
+
|
|
133
|
+
if (clean(text) !== "") return clean(text);
|
|
134
|
+
}
|
|
135
|
+
if (isInput(element) || isTextArea(element) || isSelect(element)) {
|
|
136
|
+
const labels = element.labels === null ? "" : Array.from(element.labels, labelText).join(" ");
|
|
137
|
+
|
|
138
|
+
if (clean(labels) !== "") return clean(labels);
|
|
139
|
+
if (
|
|
140
|
+
isInput(element) &&
|
|
141
|
+
(element.type === "button" || element.type === "submit" || element.type === "reset")
|
|
142
|
+
)
|
|
143
|
+
return clean(element.value);
|
|
144
|
+
|
|
145
|
+
return clean(
|
|
146
|
+
element.getAttribute("placeholder") ??
|
|
147
|
+
element.getAttribute("title") ??
|
|
148
|
+
element.getAttribute("name"),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (role === "img") return clean(element.getAttribute("alt") ?? element.getAttribute("title"));
|
|
152
|
+
if (role === "iframe")
|
|
153
|
+
return clean(element.getAttribute("title") ?? element.getAttribute("name"));
|
|
154
|
+
if (role === null || interactiveRoles.has(role) || role === "heading") {
|
|
155
|
+
// An editable secret field's text is what was typed into it.
|
|
156
|
+
const text = isSecret(element) ? "" : clean(textOf(element), 120);
|
|
157
|
+
|
|
158
|
+
if (text !== "") return text;
|
|
159
|
+
const titled = element.querySelector("[title],img[alt],svg title");
|
|
160
|
+
|
|
161
|
+
const fallback =
|
|
162
|
+
titled === null
|
|
163
|
+
? null
|
|
164
|
+
: titled.tagName === "IMG"
|
|
165
|
+
? titled.getAttribute("alt")
|
|
166
|
+
: titled.tagName.toLowerCase() === "title"
|
|
167
|
+
? titled.textContent
|
|
168
|
+
: titled.getAttribute("title");
|
|
169
|
+
|
|
170
|
+
return clean(element.getAttribute("title") ?? fallback);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return "";
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const isDisabled = (element: Element): boolean =>
|
|
177
|
+
("disabled" in element && element.disabled === true) ||
|
|
178
|
+
element.getAttribute("aria-disabled") === "true";
|
|
179
|
+
|
|
180
|
+
const refFor = (element: Element): string => {
|
|
181
|
+
const known = byElement.get(element);
|
|
182
|
+
|
|
183
|
+
if (known !== undefined && byRef.get(known)?.deref() === element) return known;
|
|
184
|
+
const ref = `e${refs.next++}`;
|
|
185
|
+
|
|
186
|
+
byElement.set(element, ref);
|
|
187
|
+
byRef.set(ref, new WeakRef(element));
|
|
188
|
+
|
|
189
|
+
return ref;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// A ref names an element of the current documents only: nodes of a navigated frame can stay
|
|
193
|
+
// connected to their old document, and must not be measured or typed into.
|
|
194
|
+
const lookup = (ref: string): Element | undefined => {
|
|
195
|
+
const element = byRef.get(ref)?.deref();
|
|
196
|
+
|
|
197
|
+
return element !== undefined && element.isConnected && inCurrentDocument(element)
|
|
198
|
+
? element
|
|
199
|
+
: undefined;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const describe = (element: Element): string => {
|
|
203
|
+
const id = element.id === "" ? "" : `#${element.id}`;
|
|
204
|
+
const name = clean(nameOf(element, roleOf(element)), 40);
|
|
205
|
+
|
|
206
|
+
return `<${element.tagName.toLowerCase()}${id}>${name === "" ? "" : ` "${name}"`}`;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const isRoot = (node: Node): node is Document | ShadowRoot =>
|
|
210
|
+
node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
|
|
211
|
+
|
|
212
|
+
const parentOf = (element: Element): Element | null => {
|
|
213
|
+
const root = element.getRootNode();
|
|
214
|
+
|
|
215
|
+
return element.parentElement ?? (isRoot(root) && "host" in root ? root.host : null);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const activeElement = (): Element | null => {
|
|
219
|
+
let active = document.activeElement;
|
|
220
|
+
|
|
221
|
+
while (active !== null) {
|
|
222
|
+
const inner =
|
|
223
|
+
active.shadowRoot?.activeElement ??
|
|
224
|
+
(isFrame(active) ? active.contentDocument?.activeElement : null);
|
|
225
|
+
|
|
226
|
+
if (inner === null || inner === undefined || inner === active) return active;
|
|
227
|
+
active = inner;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const inCurrentDocument = (element: Element): boolean => {
|
|
234
|
+
let owner = element.ownerDocument;
|
|
235
|
+
|
|
236
|
+
// Nodes in a navigated iframe can remain connected to their old Document. Approval
|
|
237
|
+
// requires the current frame chain as well as the ref's weakly held node identity.
|
|
238
|
+
while (owner !== document) {
|
|
239
|
+
const frame = owner.defaultView?.frameElement;
|
|
240
|
+
|
|
241
|
+
if (
|
|
242
|
+
frame === null ||
|
|
243
|
+
frame === undefined ||
|
|
244
|
+
!frame.isConnected ||
|
|
245
|
+
(isFrame(frame) && frame.contentDocument !== owner)
|
|
246
|
+
)
|
|
247
|
+
return false;
|
|
248
|
+
owner = frame.ownerDocument;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return true;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// Autocomplete tokens for a password, a one-time code, or a payment card's number, code or expiry.
|
|
255
|
+
const secretToken =
|
|
256
|
+
/(?:^|\s)(?:current-password|new-password|one-time-code|cc-number|cc-csc|cc-exp(?:-month|-year)?)(?=\s|$)/i;
|
|
257
|
+
|
|
258
|
+
// A field once secret stays secret, such as a password its page lets the user reveal.
|
|
259
|
+
const secrets = new WeakSet<Element>();
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A field the DOM marks as secret, now or before: by its type, its autocomplete tokens, or a
|
|
263
|
+
* style that shows dots for what it holds, as a PIN field may.
|
|
264
|
+
*/
|
|
265
|
+
const isSecret = (element: Element): boolean => {
|
|
266
|
+
if (
|
|
267
|
+
(isInput(element) && element.type === "password") ||
|
|
268
|
+
secretToken.test(element.getAttribute("autocomplete") ?? "") ||
|
|
269
|
+
((isInput(element) || isTextArea(element)) &&
|
|
270
|
+
!/^(none)?$/.test(getComputedStyle(element).getPropertyValue("-webkit-text-security")))
|
|
271
|
+
)
|
|
272
|
+
secrets.add(element);
|
|
273
|
+
|
|
274
|
+
return secrets.has(element);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
activeElement,
|
|
279
|
+
clean,
|
|
280
|
+
containers,
|
|
281
|
+
describe,
|
|
282
|
+
inCurrentDocument,
|
|
283
|
+
interactiveRoles,
|
|
284
|
+
isButton,
|
|
285
|
+
isDisabled,
|
|
286
|
+
isDocument,
|
|
287
|
+
isElement,
|
|
288
|
+
isFrame,
|
|
289
|
+
isHtml,
|
|
290
|
+
isInput,
|
|
291
|
+
isLabel,
|
|
292
|
+
isRoot,
|
|
293
|
+
isSecret,
|
|
294
|
+
isSelect,
|
|
295
|
+
isTextArea,
|
|
296
|
+
lookup,
|
|
297
|
+
nameOf,
|
|
298
|
+
parentOf,
|
|
299
|
+
refFor,
|
|
300
|
+
refs,
|
|
301
|
+
roleOf,
|
|
302
|
+
textOf,
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
export type Names = ReturnType<typeof names>;
|