effect-browser 0.2.0-beta.9 → 0.3.0-beta.6

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 +707 -1184
  2. package/dist/Browser-B5AV7zPS.mjs +437 -0
  3. package/dist/Browser-B5AV7zPS.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-5MdsRzE5.mjs +314 -0
  44. package/dist/Plan-5MdsRzE5.mjs.map +1 -0
  45. package/dist/Plan-CNNOU1Tx.d.mts +112 -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-BkRcLCWZ.mjs +5097 -0
  73. package/dist/page-BkRcLCWZ.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 +354 -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 +610 -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,242 @@
1
+ /**
2
+ * Where a page's screencast runs: on the page's own session, or on a capture connection that the
3
+ * provider supplies, where frames and their acknowledgements never wait behind a large message on
4
+ * the connection that drives the page. There the page's session attaches as its first capture
5
+ * starts, with the Page domain on, and stays until the page closes or the session ends; the next
6
+ * capture then attaches again.
7
+ *
8
+ * Frames take their document from the commits their own session reports, in the order the browser
9
+ * sent them, so a frame never shows a document after the one it carries, as on the page's own
10
+ * session. They are numbered as the page's own session counts documents: a commit it has counted
11
+ * keeps its number, and one the capture connection sees first takes the next, until it is counted.
12
+ */
13
+ import { Effect, Exit, Option, Schema, Scope } from "effect";
14
+
15
+ import type { CaptureSource } from "../../Browser.ts";
16
+ import { type BrowserError, Failed } from "../../BrowserError.ts";
17
+ import { type Bridge, Commit, commitOf, mainFrame, MainFrameEvent } from "../page/bridge.ts";
18
+ import { type PageContext, undispatched } from "../page/context.ts";
19
+
20
+ /** Where a capture's screencast runs. Its calls never throw: a failure rejects. */
21
+ export interface Transport {
22
+ readonly start: (settings: {
23
+ readonly format: "jpeg";
24
+ readonly quality: number;
25
+ readonly maxWidth?: number;
26
+ readonly maxHeight?: number;
27
+ }) => Promise<unknown>;
28
+ readonly acknowledge: (frame: number) => Promise<unknown>;
29
+ readonly stop: () => Promise<unknown>;
30
+ /**
31
+ * A round trip whose answer comes behind every frame sent before it. On the page's own session,
32
+ * the page's own calls are such round trips already, so it sends nothing.
33
+ */
34
+ readonly sync: () => Promise<unknown>;
35
+ /**
36
+ * Hand its frames to `on.frame` as they arrive, and its end to `on.lost` should it end, until
37
+ * the returned function is called.
38
+ */
39
+ readonly listen: (on: {
40
+ readonly frame: (frame: NativeFrame) => void;
41
+ readonly lost: (error: BrowserError) => void;
42
+ }) => () => void;
43
+ /** What a frame arriving now shows: its session, the document it followed and the address then. */
44
+ readonly frameTag: () => {
45
+ readonly session: string;
46
+ readonly document: number;
47
+ readonly url: string;
48
+ };
49
+ }
50
+
51
+ /** The page's own session. */
52
+ export const own = (page: PageContext, bridge: Bridge): Transport => {
53
+ const { send } = page.protocol;
54
+
55
+ return {
56
+ start: (settings) => send("Page.startScreencast", settings),
57
+ acknowledge: (frame) => send("Page.screencastFrameAck", { sessionId: frame }),
58
+ stop: () => send("Page.stopScreencast"),
59
+ sync: () => Promise.resolve(),
60
+ listen: ({ frame }) => {
61
+ page.cdp.on("Page.screencastFrame", frame);
62
+
63
+ return () => {
64
+ page.cdp.off("Page.screencastFrame", frame);
65
+ };
66
+ },
67
+ frameTag: bridge.frameTag,
68
+ };
69
+ };
70
+
71
+ const FrameTree = Schema.Struct({ frameTree: Schema.Struct({ frame: Commit }) });
72
+
73
+ /** A native screencast frame, as Chromium sends it. */
74
+ const ScreencastFrame = Schema.Struct({
75
+ data: Schema.String,
76
+ sessionId: Schema.Int,
77
+ metadata: Schema.Struct({
78
+ timestamp: Schema.optionalKey(Schema.Finite),
79
+ deviceWidth: Schema.Finite,
80
+ deviceHeight: Schema.Finite,
81
+ }),
82
+ });
83
+
84
+ export type NativeFrame = typeof ScreencastFrame.Type;
85
+
86
+ /**
87
+ * The document and address a capture connection's frames show, from the commits its session
88
+ * reports after `first`, the main frame's document as the session began.
89
+ */
90
+ const documents = (bridge: Bridge, first: ReturnType<typeof commitOf>) => {
91
+ // The number the page's own session gave the latest commit of `loader`, if it has counted one.
92
+ const counted = (loader: string) => bridge.documentOf(loader) ?? -1;
93
+ const latest = bridge.frameTag().document;
94
+
95
+ // A loader the page's own session hasn't counted began the document it had from the start, or
96
+ // a commit still on its way to it.
97
+ const shown = {
98
+ ...first,
99
+ document: counted(first.loader) >= 0 ? counted(first.loader) : latest === 0 ? 0 : latest + 1,
100
+ };
101
+
102
+ return {
103
+ /** A commit or a move the session reported, in the order the browser sent them. */
104
+ see: (seen: ReturnType<typeof mainFrame>) => {
105
+ if (seen?.loader !== undefined) {
106
+ shown.document = Math.max(shown.document + 1, counted(seen.loader));
107
+ shown.loader = seen.loader;
108
+ }
109
+ if (seen !== undefined) shown.url = seen.url;
110
+ },
111
+ tag: () => {
112
+ shown.document = Math.max(shown.document, counted(shown.loader));
113
+
114
+ return { document: shown.document, url: shown.url };
115
+ },
116
+ };
117
+ };
118
+
119
+ type State =
120
+ | { readonly _tag: "Detached" }
121
+ | { readonly _tag: "Attaching"; readonly scope: Scope.Closeable }
122
+ | { readonly _tag: "Attached"; readonly scope: Scope.Closeable; readonly transport: Transport }
123
+ | { readonly _tag: "Ended"; readonly scope: Scope.Closeable; readonly error: BrowserError };
124
+
125
+ /**
126
+ * The page's session on the capture connection, attached when a capture asks for it and kept for
127
+ * the page's scope. Its state changes only here: attaching, attached, or ended by the session's
128
+ * end, which also ends the capture listening to it.
129
+ */
130
+ export const apart = (
131
+ page: PageContext,
132
+ bridge: Bridge,
133
+ source: CaptureSource,
134
+ error: (cause: unknown) => BrowserError,
135
+ pageScope: Scope.Scope,
136
+ ): Effect.Effect<Transport, BrowserError> => {
137
+ let state: State = { _tag: "Detached" };
138
+ let listening: Parameters<Transport["listen"]>[0] | undefined;
139
+ const latest = (): State => state;
140
+
141
+ const end = (scope: Scope.Closeable, ended: BrowserError) => {
142
+ if ((state._tag !== "Attaching" && state._tag !== "Attached") || state.scope !== scope) return;
143
+ const attached = state._tag === "Attached";
144
+
145
+ state = { _tag: "Ended", scope, error: ended };
146
+ if (attached) listening?.lost(ended);
147
+ };
148
+
149
+ const unreadable = undispatched(
150
+ "screencast",
151
+ new Failed({ detail: "the capture connection sent an event it could not read" }),
152
+ );
153
+
154
+ const attach = (scope: Scope.Closeable) =>
155
+ Effect.gen(function* () {
156
+ let shown: ReturnType<typeof documents> | undefined;
157
+
158
+ // Commits before the frame tree's answer are in it already.
159
+ const event = (method: string, params: unknown) => {
160
+ const decoded = <A>(schema: Schema.Codec<A, unknown>, act: (value: A) => void) =>
161
+ Option.match(Schema.decodeUnknownOption(schema)(params), {
162
+ onNone: () => end(scope, unreadable),
163
+ onSome: act,
164
+ });
165
+
166
+ if (method === "Page.screencastFrame")
167
+ decoded(ScreencastFrame, (frame: NativeFrame) => listening?.frame(frame));
168
+ else if (method === "Page.frameNavigated" || method === "Page.navigatedWithinDocument")
169
+ decoded(MainFrameEvent, (event) => shown?.see(mainFrame(page.id, event)));
170
+ else if (method === "Target.detachedFromTarget")
171
+ end(scope, error(new Error("Target page has been closed")));
172
+ };
173
+
174
+ const send = yield* source.attach(page.id, { event, lost: (lost) => end(scope, lost) });
175
+
176
+ // Tracked from the frame tree's answer on, set as it arrives so that no commit after it is
177
+ // missed.
178
+ const tracked = yield* Effect.tryPromise({
179
+ try: () =>
180
+ Promise.all([send("Page.enable"), send("Page.getFrameTree")]).then(([, tree]) =>
181
+ Option.match(Schema.decodeUnknownOption(FrameTree)(tree), {
182
+ onNone: () => Promise.reject(unreadable),
183
+ onSome: ({ frameTree }) => (shown = documents(bridge, commitOf(frameTree.frame))),
184
+ }),
185
+ ),
186
+ catch: error,
187
+ });
188
+
189
+ return {
190
+ start: (settings) => send("Page.startScreencast", settings),
191
+ acknowledge: (frame) => send("Page.screencastFrameAck", { sessionId: frame }),
192
+ stop: () => send("Page.stopScreencast"),
193
+ sync: () => send("Page.getFrameTree"),
194
+ listen: (on) => {
195
+ listening = on;
196
+
197
+ return () => {
198
+ if (listening === on) listening = undefined;
199
+ };
200
+ },
201
+ frameTag: () => ({ session: page.session, ...tracked.tag() }),
202
+ } satisfies Transport;
203
+ }).pipe(Scope.provide(scope));
204
+
205
+ const current: Effect.Effect<Transport, BrowserError> = Effect.suspend(() => {
206
+ switch (state._tag) {
207
+ case "Attached":
208
+ return Effect.succeed(state.transport);
209
+ case "Ended": {
210
+ const { scope } = state;
211
+
212
+ state = { _tag: "Detached" };
213
+
214
+ return Scope.close(scope, Exit.void).pipe(Effect.andThen(current));
215
+ }
216
+ case "Detached":
217
+ case "Attaching":
218
+ return Effect.gen(function* () {
219
+ const scope = yield* Scope.fork(pageScope);
220
+
221
+ state = { _tag: "Attaching", scope };
222
+ const attached = yield* Effect.exit(attach(scope).pipe(page.within("screencast")));
223
+ // `end` may have changed it meanwhile.
224
+ const settled = latest();
225
+
226
+ // A session that ended as it attached is never handed out.
227
+ if (Exit.isSuccess(attached) && settled._tag === "Attaching" && settled.scope === scope) {
228
+ state = { _tag: "Attached", scope, transport: attached.value };
229
+
230
+ return attached.value;
231
+ }
232
+ state = { _tag: "Detached" };
233
+ yield* Scope.close(scope, Exit.void);
234
+ if (Exit.isFailure(attached)) return yield* attached;
235
+
236
+ return yield* settled._tag === "Ended" ? settled.error : unreadable;
237
+ });
238
+ }
239
+ });
240
+
241
+ return current;
242
+ };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Choosing the one element a recorded subject names, among those `find` gives for its role and
3
+ * name. Context tells them apart, and an element that does not repeat every part of the recorded
4
+ * context is another subject, so a sole element left once its own has gone, such as Alice's
5
+ * "Remove" after Bob has left, drifts rather than stands in:
6
+ *
7
+ * - a row is read whole: a row named "Wrapped BTC" is not the row "BTC". Laid out again without
8
+ * rows, as cards, one of the element's fields must start with the row's words;
9
+ * - a column counts where the element is under one;
10
+ * - a label or heading must read the same, or its words appear, whole and in order, in one of the
11
+ * element's fields, as `near` reads them.
12
+ *
13
+ * The rest are ranked by how much of the recorded context they read the same. There is no
14
+ * ordinal, so a tie stays a tie.
15
+ */
16
+ import { Effect, Schema } from "effect";
17
+
18
+ import type { Subject, SubjectContext } from "../../BrowserEvent.ts";
19
+ import type { Found } from "../../Page.ts";
20
+ import { match } from "./match.inpage.ts";
21
+
22
+ /** Nothing on the page has the step's role and name. */
23
+ export class Missing extends Schema.TaggedError<Missing>()("Missing", {}) {
24
+ override get message() {
25
+ return "nothing on the page has the recorded role and name";
26
+ }
27
+ }
28
+
29
+ /** Several elements match the step's subject equally well, and nothing tells them apart. */
30
+ export class Ambiguous extends Schema.TaggedError<Ambiguous>()("Ambiguous", {
31
+ count: Schema.Int,
32
+ }) {
33
+ override get message() {
34
+ return `${this.count} elements match the recorded subject equally well`;
35
+ }
36
+ }
37
+
38
+ /** The page is not where the recording was: another site, or no element with its context. */
39
+ export class Drifted extends Schema.TaggedError<Drifted>()("Drifted", {
40
+ detail: Schema.String,
41
+ }) {
42
+ override get message() {
43
+ return this.detail;
44
+ }
45
+ }
46
+
47
+ const { holds, normalize } = match();
48
+ const fields = ["row", "column", "label", "heading"] as const;
49
+
50
+ const said = (context: SubjectContext) =>
51
+ Object.values(context).filter((value) => typeof value === "string");
52
+
53
+ /** How an element repeats one recorded field: 2 read the same, 1 found as words, 0 not at all. */
54
+ const repeats = (field: (typeof fields)[number], value: string, context: SubjectContext) => {
55
+ const own = context[field];
56
+
57
+ if (own !== undefined && normalize(own) === normalize(value)) return 2;
58
+ if (field === "column") return own === undefined ? 1 : 0;
59
+ if (field === "row")
60
+ return own === undefined &&
61
+ said(context).some((other) => `${normalize(other)} `.startsWith(`${normalize(value)} `))
62
+ ? 1
63
+ : 0;
64
+
65
+ return said(context).some((other) => holds(other, value)) ? 1 : 0;
66
+ };
67
+
68
+ export const choose = (
69
+ subject: Subject,
70
+ found: ReadonlyArray<Found>,
71
+ ): Effect.Effect<Found, Missing | Ambiguous | Drifted> => {
72
+ // `find` matched the role; a subject without one only matches elements without one.
73
+ const named = found.filter((one) => subject.role !== null || one.subject.role === null);
74
+
75
+ const kept = named.flatMap((one) => {
76
+ let score = 0;
77
+
78
+ for (const field of fields) {
79
+ const value = subject.context[field];
80
+ const repeated = value === undefined ? 0 : repeats(field, value, one.subject.context);
81
+
82
+ if (value !== undefined && repeated === 0) return [];
83
+ score += repeated;
84
+ }
85
+
86
+ return [{ one, score }];
87
+ });
88
+
89
+ const best = Math.max(...kept.map(({ score }) => score));
90
+ const top = kept.filter(({ score }) => score === best);
91
+ const [only] = top;
92
+
93
+ if (named.length === 0) return Effect.fail(new Missing());
94
+ if (kept.length === 0)
95
+ return Effect.fail(new Drifted({ detail: "no element named so repeats the recorded context" }));
96
+
97
+ return top.length === 1 && only !== undefined
98
+ ? Effect.succeed(only.one)
99
+ : Effect.fail(new Ambiguous({ count: top.length }));
100
+ };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * In the page: an element's context, the words around it that say which one it is:
3
+ *
4
+ * - in a table, its row, named by the row's header or else its first cell with letters, so a rank
5
+ * or a price never names it, and its column, named by the header over it, so a value is never
6
+ * read under the wrong header;
7
+ * - elsewhere, the words just before it in its row, item, group or block;
8
+ * - the heading above it, unless it is pinned to the viewport, where the markup says nothing.
9
+ *
10
+ * See `names.inpage.ts` for what a page-side part may use.
11
+ */
12
+ import type { SubjectContext } from "../../BrowserEvent.ts";
13
+ import type { Names } from "./names.inpage.ts";
14
+ import type { Walk } from "./walk.inpage.ts";
15
+
16
+ /** What one read has learnt of the page: each table's header row and each tree's headings. */
17
+ export interface Known {
18
+ readonly headers: Map<Element, Element | null>;
19
+ readonly headings: Map<Node, ReadonlyArray<Element>>;
20
+ }
21
+
22
+ export const context = (names: Names, walked: Walk) => {
23
+ const { clean, isButton, isDocument, isHtml, isInput, isRoot } = names;
24
+ const { isSelect, isTextArea, roleOf, textOf } = names;
25
+ const { visible } = walked;
26
+
27
+ const known = (): Known => ({ headers: new Map(), headings: new Map() });
28
+
29
+ // Text in these says what a control is called, not what the page says around it.
30
+ const unspoken =
31
+ "button,select,textarea,option,script,style,noscript,template,[role=button],[role=menuitem],[role=option],[role=tab],[role=switch],[role=checkbox],[role=radio],[aria-hidden=true]";
32
+
33
+ // Containers whose text usually describes the controls in them: a row, an item, a group or a form.
34
+ const groups =
35
+ "tr,li,article,fieldset,form,section,dialog,[role=row],[role=listitem],[role=group],[role=region],[role=dialog],[role=alertdialog]";
36
+
37
+ /** The visible text just before (or after) a target within `scope`, nearest kept, ≤120 characters. */
38
+ const textBeside = (target: Element, scope: Node, backwards: boolean): string => {
39
+ const walker = target.ownerDocument.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
40
+
41
+ const labels =
42
+ isInput(target) || isTextArea(target) || isSelect(target) || isButton(target)
43
+ ? Array.from(target.labels ?? [])
44
+ : [];
45
+
46
+ let text = "";
47
+
48
+ walker.currentNode = target;
49
+ for (let steps = 0; steps < 400 && text.replace(/\s+/g, "").length < 120; steps++) {
50
+ const node = backwards ? walker.previousNode() : walker.nextNode();
51
+
52
+ if (node === null) break;
53
+ const parent = node.parentElement;
54
+
55
+ if (
56
+ parent === null ||
57
+ target.contains(parent) ||
58
+ parent.closest(unspoken) !== null ||
59
+ (isHtml(parent) && parent.isContentEditable) ||
60
+ labels.some((label) => label.contains(parent)) ||
61
+ !visible(parent)
62
+ )
63
+ continue;
64
+ text = backwards ? `${node.textContent ?? ""} ${text}` : `${text} ${node.textContent ?? ""}`;
65
+ }
66
+ const words = text.replace(/\s+/g, " ").trim();
67
+
68
+ return words.length <= 120
69
+ ? words
70
+ : backwards
71
+ ? `…${words.slice(-119).trimStart()}`
72
+ : `${words.slice(0, 119).trimEnd()}…`;
73
+ };
74
+
75
+ /** The nearest visible heading before `target` in tree order, within `scope`. */
76
+ const headingBefore = (
77
+ target: Element,
78
+ scope: ParentNode,
79
+ read = known(),
80
+ ): string | undefined => {
81
+ let headings = read.headings.get(scope);
82
+
83
+ if (headings === undefined) {
84
+ headings = Array.from(scope.querySelectorAll("h1,h2,h3,h4,h5,h6,[role=heading]")).filter(
85
+ (element) => roleOf(element) === "heading",
86
+ );
87
+ read.headings.set(scope, headings);
88
+ }
89
+ // Headings are in tree order, so the nearest before the target is the last one before it.
90
+ for (const heading of headings.toReversed()) {
91
+ if ((heading.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING) === 0)
92
+ continue;
93
+ const text = visible(heading) ? clean(textOf(heading), 120) : "";
94
+
95
+ if (text !== "") return text;
96
+ }
97
+
98
+ return undefined;
99
+ };
100
+
101
+ /** Whether an element is pinned to the viewport, where the markup around it says nothing. */
102
+ const pinned = (element: Element): boolean => {
103
+ if (!isHtml(element)) return false;
104
+ let top: HTMLElement = element;
105
+
106
+ while (top.offsetParent !== null && isHtml(top.offsetParent)) top = top.offsetParent;
107
+
108
+ return (
109
+ top !== element.ownerDocument.body &&
110
+ top !== element.ownerDocument.documentElement &&
111
+ getComputedStyle(top).position === "fixed"
112
+ );
113
+ };
114
+
115
+ const span = (cell: Element): number => Math.max(1, Number(cell.getAttribute("colspan")) || 1);
116
+ const tables = "table,[role=table],[role=grid],[role=treegrid]";
117
+
118
+ /** A cell's row and column names, or nothing when it is not in a table with either. */
119
+ const tableContext = (cell: Element, read: Known): SubjectContext => {
120
+ const row = cell.closest("tr,[role=row]");
121
+ const table = row?.closest(tables);
122
+
123
+ if (row === null || row === undefined || table === null || table === undefined) return {};
124
+ let header = read.headers.get(table);
125
+
126
+ if (header === undefined) {
127
+ const first = table.querySelector(
128
+ "thead tr, tr:has(> th), [role=row]:has(> [role=columnheader])",
129
+ );
130
+
131
+ header = first?.closest(tables) === table ? first : null;
132
+ read.headers.set(table, header);
133
+ }
134
+ if (row === header) return {};
135
+ const cells = Array.from(row.children);
136
+ const index = cells.indexOf(cell);
137
+ // A rank, a checkbox, a star or a price says where a row is, not which one it is.
138
+ const lettered = (other: Element) => /\p{L}/u.test(textOf(other));
139
+
140
+ const named =
141
+ cells.find((other) => other.matches("th,[role=rowheader]") && lettered(other)) ??
142
+ cells.find((other) => other !== cell && lettered(other));
143
+
144
+ // Columns count spanned cells, so a cell after one spanning two is under the third header.
145
+ let left = cells.slice(0, index).reduce((sum, other) => sum + span(other), 0);
146
+
147
+ const over =
148
+ index === -1 || header === null
149
+ ? undefined
150
+ : Array.from(header.children).find((candidate) => (left -= span(candidate)) < 0);
151
+
152
+ return {
153
+ row:
154
+ named === undefined || named === cell ? undefined : clean(textOf(named), 60) || undefined,
155
+ column: over === undefined ? undefined : clean(textOf(over), 60) || undefined,
156
+ };
157
+ };
158
+
159
+ /** Where text around an element is read: its dialog, else its own tree, never across a shadow root. */
160
+ const treeOf = (element: Element): ParentNode => {
161
+ const root = element.getRootNode();
162
+
163
+ return (
164
+ element.closest("dialog,[role=dialog],[role=alertdialog]") ??
165
+ (isDocument(root) ? (root.body ?? root) : isRoot(root) ? root : element.ownerDocument)
166
+ );
167
+ };
168
+
169
+ /** The words around an element that say which one it is. */
170
+ const contextOf = (element: Element, read = known()): SubjectContext => {
171
+ const cell = element.closest(
172
+ "td,th,[role=cell],[role=gridcell],[role=rowheader],[role=columnheader]",
173
+ );
174
+
175
+ const table = cell === null ? {} : tableContext(cell, read);
176
+ const parent = element.parentElement;
177
+
178
+ const scope =
179
+ element.closest(groups) ??
180
+ (parent === null || parent === element.ownerDocument.body || parent.tagName === "HTML"
181
+ ? undefined
182
+ : parent);
183
+
184
+ const beside =
185
+ table.row !== undefined || table.column !== undefined || scope === undefined
186
+ ? ""
187
+ : textBeside(element, scope, true);
188
+
189
+ const heading = pinned(element) ? undefined : headingBefore(element, treeOf(element), read);
190
+
191
+ return {
192
+ ...table,
193
+ label: beside.length > 40 ? `…${beside.slice(-39).trimStart()}` : beside || undefined,
194
+ heading: heading === undefined ? undefined : clean(heading, 60),
195
+ };
196
+ };
197
+
198
+ return { contextOf, groups, headingBefore, known, textBeside, treeOf };
199
+ };
200
+
201
+ export type ContextReader = ReturnType<typeof context>;