effect-browser 0.2.0-beta.9 → 0.3.0-beta.11
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 +861 -1161
- package/dist/Browser-B4qncxe7.mjs +628 -0
- package/dist/Browser-B4qncxe7.mjs.map +1 -0
- package/dist/Browser.d.mts +129 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +211 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +297 -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-iMlcuzVD.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-Di2PxyF7.d.mts +119 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +125 -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-B5cjaUp5.mjs +618 -0
- package/dist/Page-B5cjaUp5.mjs.map +1 -0
- package/dist/Page-B7X6DQUn.d.mts +595 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-Akcac63g.d.mts +209 -0
- package/dist/Plan-D1wrJVs8.mjs +508 -0
- package/dist/Plan-D1wrJVs8.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 +64 -0
- package/dist/Stage.mjs +237 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-B6xr8nVi.mjs +261 -0
- package/dist/context-B6xr8nVi.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-BAf__WKX.mjs +5075 -0
- package/dist/page-BAf__WKX.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-Cd04Okid.mjs +264 -0
- package/dist/url-Cd04Okid.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +672 -0
- package/src/BrowserError.ts +234 -0
- package/src/BrowserEvent.ts +296 -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 +132 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +522 -0
- package/src/Plan.ts +663 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +467 -0
- package/src/Supervisor.ts +599 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +610 -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/gate.ts +224 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +184 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +376 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +195 -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 +391 -0
- package/src/internal/page/context.ts +314 -0
- package/src/internal/page/identity.ts +35 -0
- package/src/internal/page/lane.ts +311 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +165 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +776 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/counts.ts +92 -0
- package/src/internal/pictures/pictures.ts +521 -0
- package/src/internal/pictures/transport.ts +243 -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 +164 -0
- package/src/internal/reading/ready.ts +111 -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 +416 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +94 -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-BF9_sORP.d.mts +0 -388
- 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-B6nT1G4X.d.mts +0 -148
- package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
- package/dist/BrowserRuntime-C1r2aSRK.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-yCG9BmRU.mjs +0 -76
- package/dist/Images-yCG9BmRU.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-Dyxu2q8C.mjs +0 -543
- package/dist/Plan-Dyxu2q8C.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-DCSMMqOj.mjs +0 -5602
- package/dist/PublicSession-DCSMMqOj.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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: the outline a model reads, with a ref for each control. See `names.inpage.ts` for
|
|
3
|
+
* what a page-side part may use.
|
|
4
|
+
*/
|
|
5
|
+
import { Schema } from "effect";
|
|
6
|
+
|
|
7
|
+
import type { Control, Snapshot } from "../../Snapshot.ts";
|
|
8
|
+
import type { addresses } from "../page/url.ts";
|
|
9
|
+
import type { Controls } from "./controls.inpage.ts";
|
|
10
|
+
import type { Names } from "./names.inpage.ts";
|
|
11
|
+
import type { Subjects } from "./subjects.inpage.ts";
|
|
12
|
+
import type { Texts } from "./text.inpage.ts";
|
|
13
|
+
import type { Walk } from "./walk.inpage.ts";
|
|
14
|
+
|
|
15
|
+
export interface SnapshotRequest {
|
|
16
|
+
readonly full: boolean;
|
|
17
|
+
readonly query: string | null;
|
|
18
|
+
/** A CSS selector: only what is inside the elements it matches is read. */
|
|
19
|
+
readonly within: string | null;
|
|
20
|
+
readonly maxChars: number;
|
|
21
|
+
readonly firstRef: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The outline as a `Snapshot` reads it, and the next ref the page may give, or why `within` is not
|
|
26
|
+
* a selector.
|
|
27
|
+
*/
|
|
28
|
+
export type SnapshotResult =
|
|
29
|
+
| { readonly snapshot: typeof Snapshot.Encoded; readonly nextRef: number }
|
|
30
|
+
| { readonly invalid: string };
|
|
31
|
+
|
|
32
|
+
/** The rule every address the library reports goes by, which a link's address goes by too. */
|
|
33
|
+
type Url = ReturnType<typeof addresses>;
|
|
34
|
+
|
|
35
|
+
export const outline = (
|
|
36
|
+
names: Names,
|
|
37
|
+
walked: Walk,
|
|
38
|
+
subjects: Subjects,
|
|
39
|
+
texts: Texts,
|
|
40
|
+
controlling: Controls,
|
|
41
|
+
url: Url,
|
|
42
|
+
) => {
|
|
43
|
+
const { clean, containers, isFrame, nameOf, refFor, refs, roleOf, textOf } = names;
|
|
44
|
+
const { visit } = walked;
|
|
45
|
+
const { isControl } = subjects;
|
|
46
|
+
const { cut } = texts;
|
|
47
|
+
|
|
48
|
+
const textBlocks =
|
|
49
|
+
/^(?:P|LI|TD|TH|DT|DD|LABEL|SPAN|BLOCKQUOTE|FIGCAPTION|CAPTION|PRE|STRONG|EM|B|I|SMALL|TIME|CODE|LEGEND)$/;
|
|
50
|
+
|
|
51
|
+
const nested =
|
|
52
|
+
"a[href],button,input,select,textarea,summary,[role],[onclick],[tabindex],[contenteditable],canvas,iframe";
|
|
53
|
+
|
|
54
|
+
const noise = /^[\s|•·\-–—,.:;()[\]{}"'/\\]*$/;
|
|
55
|
+
|
|
56
|
+
/** Text inside a control's label is already that control's name. */
|
|
57
|
+
const inControlLabel = (element: Element): boolean => {
|
|
58
|
+
const label = element.closest("label");
|
|
59
|
+
|
|
60
|
+
return label !== null && label.control !== null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const hrefOf = (element: Element): string => {
|
|
64
|
+
if (element.tagName !== "A") return "";
|
|
65
|
+
const raw = element.getAttribute("href") ?? "";
|
|
66
|
+
|
|
67
|
+
if (raw === "" || raw.startsWith("javascript:")) return "";
|
|
68
|
+
try {
|
|
69
|
+
const { origin, href } = new URL(raw, element.ownerDocument.baseURI);
|
|
70
|
+
// Reported as every address is, before it is shortened, so no part of a credential shows.
|
|
71
|
+
const reported = url.redact(href);
|
|
72
|
+
const own = origin === location.origin && reported.startsWith(origin);
|
|
73
|
+
|
|
74
|
+
return ` -> ${clean(own ? reported.slice(origin.length) : reported, 80)}`;
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
interface Place {
|
|
81
|
+
readonly depth: number;
|
|
82
|
+
readonly insidePointer: boolean;
|
|
83
|
+
/** Inside a control, only the controls in it are read. */
|
|
84
|
+
readonly controls: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const snapshot = (request: SnapshotRequest): SnapshotResult => {
|
|
88
|
+
const roots = request.within === null ? [null] : controlling.rootsOf(request.within);
|
|
89
|
+
|
|
90
|
+
if ("invalid" in roots) return roots;
|
|
91
|
+
if (refs.next < request.firstRef) refs.next = request.firstRef;
|
|
92
|
+
const width = window.innerWidth;
|
|
93
|
+
const height = window.innerHeight;
|
|
94
|
+
const query = request.query === null ? null : request.query.toLowerCase();
|
|
95
|
+
const lines: Array<string> = [];
|
|
96
|
+
const controls: Array<typeof Control.Encoded> = [];
|
|
97
|
+
let above = 0;
|
|
98
|
+
let below = 0;
|
|
99
|
+
|
|
100
|
+
const placement = (rect: DOMRect): "in" | "above" | "below" | "aside" => {
|
|
101
|
+
if (request.full) return "in";
|
|
102
|
+
if (rect.bottom <= 0) return "above";
|
|
103
|
+
if (rect.top >= height) return "below";
|
|
104
|
+
if (rect.right <= 0 || rect.left >= width) return "aside";
|
|
105
|
+
|
|
106
|
+
return "in";
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const count = (rect: DOMRect) => {
|
|
110
|
+
const where = placement(rect);
|
|
111
|
+
|
|
112
|
+
if (where === "above") above++;
|
|
113
|
+
if (where === "below") below++;
|
|
114
|
+
|
|
115
|
+
return where;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const emit = (depth: number, line: string, rect: DOMRect): boolean => {
|
|
119
|
+
if (count(rect) !== "in") return false;
|
|
120
|
+
if (query !== null && !line.toLowerCase().includes(query)) return false;
|
|
121
|
+
lines.push(`${" ".repeat(Math.min(depth, 8))}- ${line}`);
|
|
122
|
+
|
|
123
|
+
return true;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const control = (element: Element, rect: DOMRect, role: string | null, depth: number) => {
|
|
127
|
+
const name = nameOf(element, role);
|
|
128
|
+
const kind = role ?? "clickable";
|
|
129
|
+
|
|
130
|
+
const box =
|
|
131
|
+
role === "canvas" || role === "iframe"
|
|
132
|
+
? ` ${Math.round(rect.width)}x${Math.round(rect.height)} at (${Math.round(rect.x)},${Math.round(rect.y)})`
|
|
133
|
+
: "";
|
|
134
|
+
|
|
135
|
+
const line =
|
|
136
|
+
placement(rect) === "in"
|
|
137
|
+
? `${kind}${name === "" ? "" : ` ${JSON.stringify(name)}`} [ref=${refFor(element)}]${controlling.states(element, role)}${controlling.valueOf(element)}${hrefOf(element)}${box}`
|
|
138
|
+
: kind;
|
|
139
|
+
|
|
140
|
+
if (emit(depth, line, rect)) controls.push(controlling.controlOf(element, role, kind, name));
|
|
141
|
+
if (isFrame(element)) {
|
|
142
|
+
const body = element.contentDocument?.body;
|
|
143
|
+
|
|
144
|
+
if ((body === null || body === undefined) && placement(rect) === "in")
|
|
145
|
+
lines.push(
|
|
146
|
+
`${" ".repeat(Math.min(depth + 1, 8))}- (another site's frame: use a screenshot and coordinates)`,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return { depth: depth + 1, insidePointer: false, controls: false };
|
|
150
|
+
}
|
|
151
|
+
if (["INPUT", "SELECT", "TEXTAREA", "CANVAS", "IMG"].includes(element.tagName))
|
|
152
|
+
return undefined;
|
|
153
|
+
|
|
154
|
+
return { depth: depth + 1, insidePointer: true, controls: true };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const enter = (
|
|
158
|
+
element: Element,
|
|
159
|
+
style: CSSStyleDeclaration,
|
|
160
|
+
rect: DOMRect,
|
|
161
|
+
{ depth, insidePointer }: Place,
|
|
162
|
+
): Place | undefined => {
|
|
163
|
+
const role = roleOf(element);
|
|
164
|
+
|
|
165
|
+
if (isControl(element, role, style, rect, insidePointer))
|
|
166
|
+
return control(element, rect, role, depth);
|
|
167
|
+
if (role === "heading") {
|
|
168
|
+
emit(
|
|
169
|
+
depth,
|
|
170
|
+
`heading ${JSON.stringify(nameOf(element, role))}${controlling.states(element, role)}`,
|
|
171
|
+
rect,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
if (role === "img") {
|
|
177
|
+
const alt = nameOf(element, role);
|
|
178
|
+
|
|
179
|
+
if (alt !== "") emit(depth, `img ${JSON.stringify(alt)}`, rect);
|
|
180
|
+
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
let inner = depth;
|
|
184
|
+
|
|
185
|
+
if (role !== null && (containers[element.tagName] !== undefined || role === "dialog")) {
|
|
186
|
+
const label = element.getAttribute("aria-label");
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
emit(depth, `${role}${label === null ? "" : ` ${JSON.stringify(clean(label))}`}:`, rect)
|
|
190
|
+
)
|
|
191
|
+
inner = depth + 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (textBlocks.test(element.tagName) && element.querySelector(nested) === null) {
|
|
195
|
+
if (inControlLabel(element)) return undefined;
|
|
196
|
+
const text = clean(textOf(element), 300);
|
|
197
|
+
|
|
198
|
+
if (text !== "" && !noise.test(text)) emit(inner, `text: ${text}`, rect);
|
|
199
|
+
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let own = "";
|
|
204
|
+
|
|
205
|
+
for (const node of Array.from(element.childNodes))
|
|
206
|
+
if (node.nodeType === Node.TEXT_NODE) own += node.textContent ?? "";
|
|
207
|
+
own = clean(own, 300);
|
|
208
|
+
if (own !== "" && !noise.test(own) && !inControlLabel(element))
|
|
209
|
+
emit(inner, `text: ${own}`, rect);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
depth: inner,
|
|
213
|
+
insidePointer: insidePointer || style.cursor === "pointer",
|
|
214
|
+
controls: false,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const visitor = {
|
|
219
|
+
enter,
|
|
220
|
+
admit: (child: Element, place: Place) =>
|
|
221
|
+
!place.controls || child.matches(nested) || child.querySelector(nested) !== null,
|
|
222
|
+
outside: count,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Inside the viewport, a subtree out of view is skipped whole and counted as one part.
|
|
226
|
+
for (const root of roots)
|
|
227
|
+
visit(root, !request.full, { depth: 0, insidePointer: false, controls: false }, visitor);
|
|
228
|
+
|
|
229
|
+
const outlined = cut(lines.join("\n"), request.maxChars);
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
snapshot: {
|
|
233
|
+
...outlined,
|
|
234
|
+
url: location.href,
|
|
235
|
+
title: document.title,
|
|
236
|
+
above,
|
|
237
|
+
below,
|
|
238
|
+
viewport: { width, height },
|
|
239
|
+
scroll: {
|
|
240
|
+
y: Math.round(window.scrollY),
|
|
241
|
+
height: Math.round(document.documentElement.scrollHeight),
|
|
242
|
+
},
|
|
243
|
+
// Only the controls whose lines were kept.
|
|
244
|
+
controls: controls.filter((control) => outlined.text.includes(`[ref=${control.ref}]`)),
|
|
245
|
+
},
|
|
246
|
+
nextRef: refs.next,
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight });
|
|
251
|
+
|
|
252
|
+
return { snapshot, viewport };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export type Outline = ReturnType<typeof outline>;
|
|
256
|
+
|
|
257
|
+
export const ViewportResultSchema = Schema.Struct({
|
|
258
|
+
width: Schema.Finite,
|
|
259
|
+
height: Schema.Finite,
|
|
260
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a page: its outline with refs, the elements a query finds and the text it shows. Each
|
|
3
|
+
* read takes its turn on the page and shares its work with identical reads, as `lane.ts`
|
|
4
|
+
* describes. The viewport's text as it was last read is kept, for `Page.state`.
|
|
5
|
+
*/
|
|
6
|
+
import { Duration, Effect, Ref, Schema } from "effect";
|
|
7
|
+
|
|
8
|
+
import { InvalidRequest, StaleRef, Timeout } from "../../BrowserError.ts";
|
|
9
|
+
import { type FindQuery, Found, Text, type TextOptions, type WaitCondition } from "../../Page.ts";
|
|
10
|
+
import { Snapshot, type SnapshotOptions } from "../../Snapshot.ts";
|
|
11
|
+
import { type Bridge, scriptCall } from "../page/bridge.ts";
|
|
12
|
+
import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
|
|
13
|
+
import * as Url from "../page/url.ts";
|
|
14
|
+
import type { ConditionRequest } from "./controls.inpage.ts";
|
|
15
|
+
import type { Want } from "./expect.inpage.ts";
|
|
16
|
+
import type { FindRequest, Wanted } from "./match.inpage.ts";
|
|
17
|
+
import type { SnapshotRequest } from "./outline.inpage.ts";
|
|
18
|
+
import { type TextRequest, TextResultSchema } from "./text.inpage.ts";
|
|
19
|
+
|
|
20
|
+
// What `snapshot` and `find` read back, each with the next ref the page may give, and why a
|
|
21
|
+
// snapshot's selector is not CSS.
|
|
22
|
+
const SnapshotResult = Schema.Union([
|
|
23
|
+
Schema.Struct({ snapshot: Snapshot, nextRef: Schema.Finite }),
|
|
24
|
+
Schema.Struct({ invalid: Schema.String }),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const FindResults = Schema.Struct({ found: Schema.Array(Found), nextRef: Schema.Finite });
|
|
28
|
+
|
|
29
|
+
const UntilResult = Schema.Struct({ met: Schema.Boolean, mark: Schema.String });
|
|
30
|
+
|
|
31
|
+
const ConditionResult = Schema.Union([
|
|
32
|
+
Schema.Struct({ met: Schema.Boolean }),
|
|
33
|
+
Schema.Struct({ invalid: Schema.String }),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// A pattern crosses into the page as its source and flags.
|
|
37
|
+
const wanted = (value: string | RegExp | undefined): Wanted | null =>
|
|
38
|
+
value === undefined
|
|
39
|
+
? null
|
|
40
|
+
: typeof value === "string"
|
|
41
|
+
? value
|
|
42
|
+
: { source: value.source, flags: value.flags };
|
|
43
|
+
|
|
44
|
+
/** A query as the page takes it, but for the first ref it may give. */
|
|
45
|
+
const askedOf = (query: FindQuery, scope: "viewport" | "document") => ({
|
|
46
|
+
role: query.role ?? null,
|
|
47
|
+
name: wanted(query.name),
|
|
48
|
+
text: wanted(query.text),
|
|
49
|
+
near: query.near ?? null,
|
|
50
|
+
at: query.at ?? null,
|
|
51
|
+
scope: query.scope ?? scope,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const make = Effect.fnUntraced(function* (page: PageContext, bridge: Bridge) {
|
|
55
|
+
const { now, span, owned, within, lane } = page;
|
|
56
|
+
const { evaluate } = bridge;
|
|
57
|
+
// Refs count up across the page's documents, so one never names two elements.
|
|
58
|
+
const nextRef = yield* Ref.make(1);
|
|
59
|
+
const counted = (next: number) => Ref.update(nextRef, (current) => Math.max(current, next));
|
|
60
|
+
const snapshots = lane.shared<Snapshot>("snapshot", true);
|
|
61
|
+
const finds = lane.shared<ReadonlyArray<Found>>("find", true);
|
|
62
|
+
const texts = lane.shared<Text>("text", true);
|
|
63
|
+
let viewed: Text | undefined;
|
|
64
|
+
|
|
65
|
+
const snapshot = (snapshotOptions: SnapshotOptions = {}) => {
|
|
66
|
+
const asked = {
|
|
67
|
+
full: snapshotOptions.full ?? false,
|
|
68
|
+
query: snapshotOptions.query ?? null,
|
|
69
|
+
within: snapshotOptions.within ?? null,
|
|
70
|
+
maxChars: snapshotOptions.maxChars ?? 12_000,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return snapshots(
|
|
74
|
+
JSON.stringify(asked),
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const request: SnapshotRequest = { ...asked, firstRef: yield* Ref.get(nextRef) };
|
|
77
|
+
|
|
78
|
+
const result = yield* evaluate("snapshot", scriptCall("snapshot", request)).pipe(
|
|
79
|
+
Effect.flatMap(decodeWith("snapshot", SnapshotResult)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if ("invalid" in result)
|
|
83
|
+
return yield* failWith("snapshot", new InvalidRequest({ detail: result.invalid }));
|
|
84
|
+
const { text, truncated, url } = result.snapshot;
|
|
85
|
+
|
|
86
|
+
yield* counted(result.nextRef);
|
|
87
|
+
yield* Effect.annotateCurrentSpan({ chars: text.length, truncated });
|
|
88
|
+
|
|
89
|
+
return new Snapshot({ ...result.snapshot, url: Url.redact(url) });
|
|
90
|
+
}).pipe(within("snapshot")),
|
|
91
|
+
).pipe(span("Page.snapshot", { full: asked.full }), owned);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const find = (query: FindQuery = {}) => {
|
|
95
|
+
const asked = askedOf(query, "viewport");
|
|
96
|
+
|
|
97
|
+
return finds(
|
|
98
|
+
JSON.stringify(asked),
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const request: FindRequest = { ...asked, firstRef: yield* Ref.get(nextRef) };
|
|
101
|
+
|
|
102
|
+
const result = yield* evaluate("find", scriptCall("find", request)).pipe(
|
|
103
|
+
Effect.flatMap(decodeWith("find", FindResults)),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
yield* counted(result.nextRef);
|
|
107
|
+
yield* Effect.annotateCurrentSpan({ found: result.found.length });
|
|
108
|
+
|
|
109
|
+
return result.found;
|
|
110
|
+
}).pipe(within("find")),
|
|
111
|
+
).pipe(
|
|
112
|
+
// The query's words are the caller's, and may be anything; only its shape is traced.
|
|
113
|
+
span("Page.find", { scope: asked.scope, role: asked.role ?? "" }),
|
|
114
|
+
owned,
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const text = (options: TextOptions = {}) => {
|
|
119
|
+
const scope = options.scope ?? "viewport";
|
|
120
|
+
|
|
121
|
+
const request: TextRequest = {
|
|
122
|
+
ref: scope === "viewport" ? null : scope,
|
|
123
|
+
maxChars: options.maxChars ?? 12_000,
|
|
124
|
+
unmask: options.unmask ?? false,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
scope !== "viewport" && !/^e\d+$/.test(scope)
|
|
129
|
+
? failWith(
|
|
130
|
+
"text",
|
|
131
|
+
new InvalidRequest({ detail: `${scope} is neither "viewport" nor a ref such as e12` }),
|
|
132
|
+
)
|
|
133
|
+
: texts(
|
|
134
|
+
JSON.stringify(request),
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
const result = yield* evaluate("text", scriptCall("text", request)).pipe(
|
|
137
|
+
Effect.flatMap(decodeWith("text", TextResultSchema)),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (result === null) return yield* failWith("text", new StaleRef({ ref: scope }));
|
|
141
|
+
yield* Effect.annotateCurrentSpan({
|
|
142
|
+
chars: result.text.length,
|
|
143
|
+
truncated: result.truncated,
|
|
144
|
+
});
|
|
145
|
+
const shown = new Text({ ...result, url: Url.redact(result.url), at: now() });
|
|
146
|
+
|
|
147
|
+
if (request.ref === null) viewed = shown;
|
|
148
|
+
|
|
149
|
+
return shown;
|
|
150
|
+
}).pipe(within("text")),
|
|
151
|
+
)
|
|
152
|
+
).pipe(span("Page.text", { scope: scope === "viewport" ? "viewport" : "ref" }), owned);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// One call, which the page answers once the condition holds or at the deadline. It follows the
|
|
156
|
+
// action in flight, as a read does, but holds no later one back while it waits, and it waits on
|
|
157
|
+
// in a document that replaces the one it began in.
|
|
158
|
+
const waitFor = (condition: WaitCondition, timeout: Duration.Input = Duration.seconds(10)) => {
|
|
159
|
+
const request: ConditionRequest = {
|
|
160
|
+
selector: condition.selector ?? null,
|
|
161
|
+
text: condition.text ?? null,
|
|
162
|
+
state: condition.state ?? "visible",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// JSON has no infinity; the deadline here ends a wait without one in any case.
|
|
166
|
+
const millis = Math.min(Duration.toMillis(timeout), Number.MAX_SAFE_INTEGER);
|
|
167
|
+
const unmet = () => failWith("waitFor", new Timeout({ millis }));
|
|
168
|
+
|
|
169
|
+
const look = evaluate("waitFor", scriptCall("waitUntil", request, millis)).pipe(
|
|
170
|
+
Effect.flatMap(decodeWith("waitFor", ConditionResult)),
|
|
171
|
+
Effect.flatMap((result) =>
|
|
172
|
+
"invalid" in result
|
|
173
|
+
? failWith("waitFor", new InvalidRequest({ detail: result.invalid }))
|
|
174
|
+
: result.met
|
|
175
|
+
? Effect.void
|
|
176
|
+
: unmet(),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return lane
|
|
181
|
+
.read("waitFor")(Effect.void)
|
|
182
|
+
.pipe(
|
|
183
|
+
Effect.andThen(Effect.retry(look, { while: contextGone })),
|
|
184
|
+
Effect.timeoutOrElse({ duration: timeout, orElse: unmet }),
|
|
185
|
+
span("Page.waitFor", { state: request.state }),
|
|
186
|
+
owned,
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// One call, which the page answers once `want` holds of what `query` matches, the whole document
|
|
191
|
+
// unless it says otherwise, or at the deadline, with the mark of what matched then. It follows
|
|
192
|
+
// the action in flight, as `waitFor` does, and the page has the action timeout past the deadline
|
|
193
|
+
// to answer.
|
|
194
|
+
const until = (query: FindQuery, want: Want, millis: number) => {
|
|
195
|
+
const look = Effect.gen(function* () {
|
|
196
|
+
const request: FindRequest = {
|
|
197
|
+
...askedOf(query, "document"),
|
|
198
|
+
firstRef: yield* Ref.get(nextRef),
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return yield* evaluate("expect", scriptCall("until", request, want, millis)).pipe(
|
|
202
|
+
Effect.flatMap(decodeWith("expect", UntilResult)),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return lane
|
|
207
|
+
.read("expect")(Effect.void)
|
|
208
|
+
.pipe(
|
|
209
|
+
Effect.andThen(Effect.retry(look, { while: contextGone })),
|
|
210
|
+
within("expect", Duration.sum(Duration.millis(millis), page.settings.actionTimeout)),
|
|
211
|
+
span("Page.expect", { want: typeof want === "string" ? want : "other" }),
|
|
212
|
+
owned,
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** The viewport's text as it was last read, if after `since`, as a document began. */
|
|
217
|
+
const viewedSince = (since: number) =>
|
|
218
|
+
viewed !== undefined && viewed.at > since ? viewed : undefined;
|
|
219
|
+
|
|
220
|
+
return { snapshot, find, text, waitFor, until, viewedSince };
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export type Reading = Effect.Success<ReturnType<typeof make>>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: whether it is ready to be shown, checked until it is or until a deadline. Its
|
|
3
|
+
* document is parsed and has painted since, nothing that ends is animating in view, its fonts and
|
|
4
|
+
* the images in view have loaded, nothing in view is marked busy (`aria-busy`), and the viewport
|
|
5
|
+
* shows something: text, a canvas drawn on, a picture or drawing larger than an icon, a video or a
|
|
6
|
+
* frame. So a canvas mounted blank, or a spinner alone, is still loading. A screen that only says
|
|
7
|
+
* "Loading…" in words reads as ready, and a WebGL canvas drawn once, without keeping its drawing,
|
|
8
|
+
* reads as blank. Asked for a quiet spell, where the page's changes are recorded, nothing in view
|
|
9
|
+
* may have changed for that long either. Content mode starts and refreshes the recorder and
|
|
10
|
+
* observes a full quiet interval from its first check in each document. After input, it waits a
|
|
11
|
+
* task and a frame. See `names.inpage.ts` for what a page-side part may use.
|
|
12
|
+
*/
|
|
13
|
+
import { Schema } from "effect";
|
|
14
|
+
|
|
15
|
+
import type { History } from "../timeline/history.inpage.ts";
|
|
16
|
+
import type { Recorder } from "../timeline/record.inpage.ts";
|
|
17
|
+
import type { Texts } from "./text.inpage.ts";
|
|
18
|
+
import type { Walk } from "./walk.inpage.ts";
|
|
19
|
+
|
|
20
|
+
/** What the page is still waiting for; none when it is ready. */
|
|
21
|
+
export const ReadinessSchema = Schema.Array(
|
|
22
|
+
Schema.Literals([
|
|
23
|
+
"load",
|
|
24
|
+
"paint",
|
|
25
|
+
"animations",
|
|
26
|
+
"images",
|
|
27
|
+
"fonts",
|
|
28
|
+
"busy",
|
|
29
|
+
"content",
|
|
30
|
+
"changing",
|
|
31
|
+
]),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export type Readiness = typeof ReadinessSchema.Type;
|
|
35
|
+
|
|
36
|
+
export const ready = (walked: Walk, texts: Texts, kept: History, recorder: Recorder) => {
|
|
37
|
+
const { inView, visible } = walked;
|
|
38
|
+
// A canvas is read through a small copy: reading it directly would give it a context of ours.
|
|
39
|
+
let copy: OffscreenCanvasRenderingContext2D | null | undefined;
|
|
40
|
+
|
|
41
|
+
const boxed = (element: Element): boolean => {
|
|
42
|
+
const rect = element.getBoundingClientRect();
|
|
43
|
+
|
|
44
|
+
return rect.width * rect.height > 0 && inView(rect);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const showing = <E extends Element>(elements: Iterable<E>): Array<E> =>
|
|
48
|
+
Array.from(elements).filter((element) => boxed(element) && visible(element));
|
|
49
|
+
|
|
50
|
+
// A canvas mounted blank is transparent all over.
|
|
51
|
+
const drawn = (canvas: HTMLCanvasElement): boolean => {
|
|
52
|
+
copy ??= new OffscreenCanvas(32, 32).getContext("2d", { willReadFrequently: true });
|
|
53
|
+
if (copy === null) return true;
|
|
54
|
+
try {
|
|
55
|
+
copy.clearRect(0, 0, 32, 32);
|
|
56
|
+
copy.imageSmoothingQuality = "high";
|
|
57
|
+
copy.drawImage(canvas, 0, 0, 32, 32);
|
|
58
|
+
|
|
59
|
+
return new Uint32Array(copy.getImageData(0, 0, 32, 32).data.buffer).some(
|
|
60
|
+
(pixel) => pixel !== 0,
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
// Another site's pictures in it keep it from being read, and it has drawn them.
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// A picture the size of an icon, such as a spinner, says nothing has come yet. Text costs a
|
|
69
|
+
// walk of the viewport, and a canvas a copy of it, so each is read only if needed.
|
|
70
|
+
const content = (): boolean =>
|
|
71
|
+
showing(document.querySelectorAll("video, iframe, embed, object")).length > 0 ||
|
|
72
|
+
showing(document.querySelectorAll("img, svg")).some((picture) => {
|
|
73
|
+
const { width, height } = picture.getBoundingClientRect();
|
|
74
|
+
|
|
75
|
+
return width > 64 && height > 64;
|
|
76
|
+
}) ||
|
|
77
|
+
(texts.read({ ref: null, maxChars: 1, unmask: false })?.text ?? "") !== "" ||
|
|
78
|
+
showing(document.querySelectorAll("canvas")).some(drawn);
|
|
79
|
+
|
|
80
|
+
// A hidden document paints no frames, so it is not ready to be shown.
|
|
81
|
+
const painted = (): Promise<boolean> => {
|
|
82
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
83
|
+
|
|
84
|
+
if (document.visibilityState === "hidden") resolve(false);
|
|
85
|
+
else requestAnimationFrame(() => resolve(true));
|
|
86
|
+
|
|
87
|
+
return promise;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// In a frame, before it is drawn: a canvas drawn every frame still holds its drawing then.
|
|
91
|
+
const check = (quietMillis: number, observed: number): Promise<Readiness> =>
|
|
92
|
+
painted().then((paints) => {
|
|
93
|
+
const waiting: Array<Readiness[number]> = [];
|
|
94
|
+
|
|
95
|
+
if (document.readyState === "loading") waiting.push("load");
|
|
96
|
+
if (!paints) waiting.push("paint");
|
|
97
|
+
// An endless animation, such as a pulsing dot, never ends, so only those that end count.
|
|
98
|
+
if (
|
|
99
|
+
document.getAnimations().some((animation) => {
|
|
100
|
+
const effect = animation.effect;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
animation.playState === "running" &&
|
|
104
|
+
effect !== null &&
|
|
105
|
+
Number.isFinite(effect.getComputedTiming().endTime) &&
|
|
106
|
+
"target" in effect &&
|
|
107
|
+
effect.target instanceof Element &&
|
|
108
|
+
boxed(effect.target)
|
|
109
|
+
);
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
waiting.push("animations");
|
|
113
|
+
if (Array.from(document.images).some((image) => !image.complete && boxed(image)))
|
|
114
|
+
waiting.push("images");
|
|
115
|
+
if (document.fonts.status !== "loaded") waiting.push("fonts");
|
|
116
|
+
if (showing(document.querySelectorAll("[aria-busy=true]")).length > 0) waiting.push("busy");
|
|
117
|
+
if (!content()) waiting.push("content");
|
|
118
|
+
if (
|
|
119
|
+
Math.max(observed, kept.changing()) >
|
|
120
|
+
performance.timeOrigin + performance.now() - quietMillis
|
|
121
|
+
)
|
|
122
|
+
waiting.push("changing");
|
|
123
|
+
|
|
124
|
+
return waiting;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check until the page is ready, or for `millis`, and say what it is still waiting for; with
|
|
129
|
+
* `quietMillis`, until nothing the record saw in view has changed for that long, too.
|
|
130
|
+
*/
|
|
131
|
+
const wait = async (millis: number, quietMillis = 0, contentOnly = false): Promise<Readiness> => {
|
|
132
|
+
const until = performance.now() + millis;
|
|
133
|
+
let observed = contentOnly ? undefined : Number.NEGATIVE_INFINITY;
|
|
134
|
+
|
|
135
|
+
for (;;) {
|
|
136
|
+
if (contentOnly) {
|
|
137
|
+
recorder.start();
|
|
138
|
+
if (kept.begun()) observed ??= performance.timeOrigin + performance.now();
|
|
139
|
+
}
|
|
140
|
+
const waiting = await check(quietMillis, observed ?? Number.POSITIVE_INFINITY);
|
|
141
|
+
|
|
142
|
+
if (waiting.length === 0 || performance.now() >= until) return waiting;
|
|
143
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
144
|
+
|
|
145
|
+
setTimeout(resolve, Math.min(50, until - performance.now()));
|
|
146
|
+
await promise;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One task, then a frame: what the page's handlers for input did at once has run, a navigation
|
|
152
|
+
* one of them started in a timer has been asked for, and their effect has painted. Says whether
|
|
153
|
+
* the page painted, which a hidden one does not.
|
|
154
|
+
*/
|
|
155
|
+
const settle = (): Promise<boolean> => {
|
|
156
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
157
|
+
|
|
158
|
+
setTimeout(resolve, 0);
|
|
159
|
+
|
|
160
|
+
return promise.then(painted);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return { wait, settle };
|
|
164
|
+
};
|