effect-browser 0.2.0-beta.9 → 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.
Files changed (202) hide show
  1. package/README.md +705 -1184
  2. package/dist/Browser-CM65Uo7f.mjs +437 -0
  3. package/dist/Browser-CM65Uo7f.mjs.map +1 -0
  4. package/dist/Browser.d.mts +115 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +202 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +295 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-DVP2IJWm.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-D4281dIi.mjs +618 -0
  39. package/dist/Page-D4281dIi.mjs.map +1 -0
  40. package/dist/Page-DYTdVhYa.d.mts +593 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-CNNOU1Tx.d.mts +112 -0
  44. package/dist/Plan-Dtrb-8im.mjs +311 -0
  45. package/dist/Plan-Dtrb-8im.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +54 -0
  59. package/dist/Stage.mjs +133 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-BtEG93gR.mjs +420 -0
  62. package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
  63. package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-DAYjATgC.mjs +137 -0
  67. package/dist/context-DAYjATgC.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-Banfm_zc.mjs +5097 -0
  73. package/dist/page-Banfm_zc.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-CcL0K7S0.mjs +262 -0
  76. package/dist/url-CcL0K7S0.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +625 -0
  79. package/src/BrowserError.ts +225 -0
  80. package/src/BrowserEvent.ts +294 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +520 -0
  89. package/src/Plan.ts +350 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +223 -0
  94. package/src/Supervisor.ts +353 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +607 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/guard.inpage.ts +605 -0
  101. package/src/internal/input/guard.ts +183 -0
  102. package/src/internal/input/keyboard.ts +112 -0
  103. package/src/internal/input/keys.ts +127 -0
  104. package/src/internal/input/perform.ts +322 -0
  105. package/src/internal/input/pointer.ts +153 -0
  106. package/src/internal/input/replies.ts +159 -0
  107. package/src/internal/input/settle.ts +50 -0
  108. package/src/internal/input/style.ts +186 -0
  109. package/src/internal/input/targets.inpage.ts +293 -0
  110. package/src/internal/input/targets.ts +148 -0
  111. package/src/internal/page/bridge.ts +386 -0
  112. package/src/internal/page/context.ts +302 -0
  113. package/src/internal/page/lane.ts +301 -0
  114. package/src/internal/page/navigation.ts +183 -0
  115. package/src/internal/page/openings.ts +87 -0
  116. package/src/internal/page/page.ts +137 -0
  117. package/src/internal/page/url.ts +100 -0
  118. package/src/internal/page/viewport.ts +43 -0
  119. package/src/internal/pictures/capture.ts +778 -0
  120. package/src/internal/pictures/clock.ts +141 -0
  121. package/src/internal/pictures/pictures.ts +520 -0
  122. package/src/internal/pictures/transport.ts +242 -0
  123. package/src/internal/reading/choose.ts +100 -0
  124. package/src/internal/reading/context.inpage.ts +201 -0
  125. package/src/internal/reading/controls.inpage.ts +158 -0
  126. package/src/internal/reading/expect.inpage.ts +70 -0
  127. package/src/internal/reading/match.inpage.ts +92 -0
  128. package/src/internal/reading/names.inpage.ts +306 -0
  129. package/src/internal/reading/outline.inpage.ts +260 -0
  130. package/src/internal/reading/reading.ts +223 -0
  131. package/src/internal/reading/ready.inpage.ts +154 -0
  132. package/src/internal/reading/ready.ts +105 -0
  133. package/src/internal/reading/subjects.inpage.ts +188 -0
  134. package/src/internal/reading/text.inpage.ts +135 -0
  135. package/src/internal/reading/walk.inpage.ts +220 -0
  136. package/src/internal/supervisor/lifecycle.ts +265 -0
  137. package/src/internal/timeline/changes.inpage.ts +216 -0
  138. package/src/internal/timeline/changes.ts +100 -0
  139. package/src/internal/timeline/events.ts +93 -0
  140. package/src/internal/timeline/fold.inpage.ts +123 -0
  141. package/src/internal/timeline/history.inpage.ts +229 -0
  142. package/src/internal/timeline/marks.inpage.ts +207 -0
  143. package/src/internal/timeline/record.inpage.ts +133 -0
  144. package/src/internal/timeline/sight.inpage.ts +122 -0
  145. package/src/internal/timeline/window.ts +96 -0
  146. package/src/internal/timeline/words.ts +65 -0
  147. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  148. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  149. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  150. package/dist/Bootstrap.d.mts +0 -3
  151. package/dist/Bootstrap.mjs +0 -2
  152. package/dist/Browser-BF9_sORP.d.mts +0 -388
  153. package/dist/Browser.mjs.map +0 -1
  154. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  155. package/dist/BrowserData.d.mts +0 -3
  156. package/dist/BrowserData.mjs +0 -695
  157. package/dist/BrowserData.mjs.map +0 -1
  158. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  159. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  160. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  161. package/dist/BrowserRuntime.d.mts +0 -3
  162. package/dist/BrowserRuntime.mjs +0 -3
  163. package/dist/Capture.d.mts +0 -56
  164. package/dist/Capture.mjs +0 -678
  165. package/dist/Capture.mjs.map +0 -1
  166. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  167. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  168. package/dist/CaptureData.d.mts +0 -219
  169. package/dist/CaptureData.mjs +0 -2
  170. package/dist/Errors.d.mts +0 -158
  171. package/dist/Errors.mjs +0 -207
  172. package/dist/Errors.mjs.map +0 -1
  173. package/dist/Images-yCG9BmRU.mjs +0 -76
  174. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  175. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  176. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  177. package/dist/PageControl.d.mts +0 -15
  178. package/dist/PageControl.mjs +0 -34
  179. package/dist/PageControl.mjs.map +0 -1
  180. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  181. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  182. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  183. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  184. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  185. package/dist/PlanData.d.mts +0 -3
  186. package/dist/PlanData.mjs +0 -268
  187. package/dist/PlanData.mjs.map +0 -1
  188. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  189. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  190. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  191. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  192. package/dist/Testing.d.mts +0 -479
  193. package/dist/Testing.mjs +0 -2147
  194. package/dist/Testing.mjs.map +0 -1
  195. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  196. package/dist/Timeline.d.mts +0 -4
  197. package/dist/Timeline.mjs +0 -20
  198. package/dist/Timeline.mjs.map +0 -1
  199. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  200. package/dist/TimelineData.d.mts +0 -3
  201. package/dist/TimelineData.mjs +0 -482
  202. 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>;