effect-browser 0.2.0-beta.8 → 0.3.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +705 -1166
- package/dist/Browser-CM65Uo7f.mjs +437 -0
- package/dist/Browser-CM65Uo7f.mjs.map +1 -0
- package/dist/Browser.d.mts +115 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +202 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +295 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-DVP2IJWm.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-BERmf_gM.d.mts +117 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +123 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-D4281dIi.mjs +618 -0
- package/dist/Page-D4281dIi.mjs.map +1 -0
- package/dist/Page-DYTdVhYa.d.mts +593 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-CNNOU1Tx.d.mts +112 -0
- package/dist/Plan-Dtrb-8im.mjs +311 -0
- package/dist/Plan-Dtrb-8im.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +54 -0
- package/dist/Stage.mjs +133 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-BtEG93gR.mjs +420 -0
- package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
- package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-DAYjATgC.mjs +137 -0
- package/dist/context-DAYjATgC.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-Banfm_zc.mjs +5097 -0
- package/dist/page-Banfm_zc.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-CcL0K7S0.mjs +262 -0
- package/dist/url-CcL0K7S0.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +625 -0
- package/src/BrowserError.ts +225 -0
- package/src/BrowserEvent.ts +294 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +130 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +520 -0
- package/src/Plan.ts +350 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +223 -0
- package/src/Supervisor.ts +353 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +607 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +183 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +322 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +159 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +386 -0
- package/src/internal/page/context.ts +302 -0
- package/src/internal/page/lane.ts +301 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +137 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +778 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/pictures.ts +520 -0
- package/src/internal/pictures/transport.ts +242 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +154 -0
- package/src/internal/reading/ready.ts +105 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +265 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +93 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-n-bKgBNN.d.mts +0 -372
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-CFe9uUTv.d.mts +0 -148
- package/dist/BrowserRuntime-HJ1yYlFS.mjs +0 -6741
- package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-CaaPNeed.mjs +0 -76
- package/dist/Images-CaaPNeed.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-D8qQ48Ic.mjs +0 -512
- package/dist/Plan-D8qQ48Ic.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-Bs61Eal8.mjs +0 -5601
- package/dist/PublicSession-Bs61Eal8.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting until a page is ready to be shown. The page's own evidence leads: one page-script call
|
|
3
|
+
* that checks in the page until nothing is left to wait for, or the time is up. A caller that also
|
|
4
|
+
* needs the screen still, such as after spinning a canvas game's reels, gives a quiet spell, and
|
|
5
|
+
* the capture must then show no frame that long. A capture's silence cannot prove the last paint
|
|
6
|
+
* arrived: a frame can be held on its way by a stalled connection, and Chromium sends frames only
|
|
7
|
+
* while few acknowledgements are unanswered. So the spell counts only while every acknowledgement
|
|
8
|
+
* is answered, and it ends with the page's evidence once more and, where the capture runs on a
|
|
9
|
+
* connection of its own, a round trip there: each answer comes behind every frame sent before it on
|
|
10
|
+
* its connection, and a frame that arrives first starts the spell again. Where the page's changes
|
|
11
|
+
* are recorded, its evidence includes them: nothing in view may have changed for the spell, which a
|
|
12
|
+
* DOM page's changes show better than its paint, and no stall on the way can hide.
|
|
13
|
+
*/
|
|
14
|
+
import { Duration, Effect, Schedule, Stream } from "effect";
|
|
15
|
+
|
|
16
|
+
import { Timeout } from "../../BrowserError.ts";
|
|
17
|
+
import type { ReadyOptions } from "../../Page.ts";
|
|
18
|
+
import { type Bridge, scriptCall } from "../page/bridge.ts";
|
|
19
|
+
import { decodeWith, failWith, type PageContext } from "../page/context.ts";
|
|
20
|
+
import type * as Capture from "../pictures/capture.ts";
|
|
21
|
+
import { ReadinessSchema } from "./ready.inpage.ts";
|
|
22
|
+
|
|
23
|
+
export const make =
|
|
24
|
+
(page: PageContext, bridge: Bridge, capture: Capture.Controller) =>
|
|
25
|
+
(options: ReadyOptions = {}) => {
|
|
26
|
+
const timeout = options.timeout ?? Duration.seconds(15);
|
|
27
|
+
|
|
28
|
+
// The page's own evidence, checked in the page until nothing is left or `until` has come.
|
|
29
|
+
const settled = (until: number) =>
|
|
30
|
+
Effect.suspend(() =>
|
|
31
|
+
bridge.evaluate(
|
|
32
|
+
"ready",
|
|
33
|
+
scriptCall(
|
|
34
|
+
"ready",
|
|
35
|
+
Math.max(0, Math.round(until - page.now())),
|
|
36
|
+
options.quietMillis ?? 0,
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
).pipe(
|
|
40
|
+
Effect.flatMap(decodeWith("ready", ReadinessSchema)),
|
|
41
|
+
Effect.tap((waiting) => Effect.annotateCurrentSpan({ waiting: waiting.join(",") })),
|
|
42
|
+
Effect.filterOrElse(
|
|
43
|
+
(waiting) => waiting.length === 0,
|
|
44
|
+
() => failWith("ready", new Timeout({ millis: Duration.toMillis(timeout) })),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// A capture the wait starts sends its first frame late, over half a second from a hosted
|
|
49
|
+
// browser, so the spell counts from that frame (#202); Chrome sends one as a capture starts,
|
|
50
|
+
// even of a still page.
|
|
51
|
+
const quiet = (millis: number, until: number) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const running = yield* capture.active;
|
|
54
|
+
let { received } = yield* capture.stats();
|
|
55
|
+
let stirred = running ? page.now() : Number.POSITIVE_INFINITY;
|
|
56
|
+
|
|
57
|
+
// How long the screen has been still: since a frame, or an unanswered acknowledgement.
|
|
58
|
+
const look = Effect.map(capture.stats(), (stats) => {
|
|
59
|
+
if (stats.received !== received || stats.ackBacklog > 0) {
|
|
60
|
+
received = stats.received;
|
|
61
|
+
stirred = page.now();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return page.now() - stirred;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const spell = Effect.repeat(look, {
|
|
68
|
+
schedule: Schedule.spaced(Duration.millis(25)),
|
|
69
|
+
until: (still) => still >= millis,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const capturing = running
|
|
73
|
+
? Effect.never
|
|
74
|
+
: capture.stream().pipe(Stream.runDrain, Effect.andThen(Effect.never));
|
|
75
|
+
|
|
76
|
+
const still = Effect.gen(function* () {
|
|
77
|
+
for (;;) {
|
|
78
|
+
yield* spell;
|
|
79
|
+
const before = received;
|
|
80
|
+
|
|
81
|
+
yield* Effect.all([settled(until), capture.sync], { concurrency: 2, discard: true });
|
|
82
|
+
if ((yield* look) >= millis && received === before) return;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return yield* Effect.raceFirst(capturing, still);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// The wait follows the action in flight, as a read does, but holds no later one back while it
|
|
90
|
+
// watches the page settle, which can take its whole timeout.
|
|
91
|
+
return page.lane
|
|
92
|
+
.read("ready")(Effect.void)
|
|
93
|
+
.pipe(
|
|
94
|
+
Effect.andThen(
|
|
95
|
+
Effect.gen(function* () {
|
|
96
|
+
const until = page.now() + Duration.toMillis(timeout);
|
|
97
|
+
|
|
98
|
+
yield* settled(until);
|
|
99
|
+
if (options.quietMillis !== undefined) yield* quiet(options.quietMillis, until);
|
|
100
|
+
}).pipe(page.within("ready", timeout)),
|
|
101
|
+
),
|
|
102
|
+
page.span("Page.ready"),
|
|
103
|
+
page.owned,
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: subjects, and `find`. A subject is what an action acts on and what `find` finds:
|
|
3
|
+
* an element's role, name and tag, plus its context (see `context.inpage.ts`). Its state is read
|
|
4
|
+
* beside it. See `names.inpage.ts` for what a page-side part may use.
|
|
5
|
+
*/
|
|
6
|
+
import type { SubjectContext } from "../../BrowserEvent.ts";
|
|
7
|
+
import type { ContextReader } from "./context.inpage.ts";
|
|
8
|
+
import type { FindRequest, Match } from "./match.inpage.ts";
|
|
9
|
+
import type { Names } from "./names.inpage.ts";
|
|
10
|
+
import type { Walk } from "./walk.inpage.ts";
|
|
11
|
+
|
|
12
|
+
export const subjects = (names: Names, walked: Walk, matching: Match, placing: ContextReader) => {
|
|
13
|
+
const { isDisabled, isInput, nameOf, refFor, refs, roleOf, textOf } = names;
|
|
14
|
+
const { acts, boxOf, controlOf, hitAt, inView } = walked;
|
|
15
|
+
const { contextOf, known } = placing;
|
|
16
|
+
|
|
17
|
+
/** What the outline lists as a control, with a ref: something a person would act on. */
|
|
18
|
+
const isControl = (
|
|
19
|
+
element: Element,
|
|
20
|
+
role: string | null,
|
|
21
|
+
style: CSSStyleDeclaration,
|
|
22
|
+
rect: DOMRect,
|
|
23
|
+
insidePointer: boolean,
|
|
24
|
+
): boolean =>
|
|
25
|
+
acts(element, role) ||
|
|
26
|
+
(style.cursor === "pointer" && !insidePointer && rect.width > 0 && rect.height > 0);
|
|
27
|
+
|
|
28
|
+
const stateOf = (element: Element, role: string | null) => {
|
|
29
|
+
// A state the page gives either way, or none.
|
|
30
|
+
const given = (attribute: string) => {
|
|
31
|
+
const value = element.getAttribute(attribute);
|
|
32
|
+
|
|
33
|
+
return value === "true" || (value === "false" ? false : undefined);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const checkable =
|
|
37
|
+
element.hasAttribute("aria-checked") ||
|
|
38
|
+
["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"].includes(role ?? "");
|
|
39
|
+
|
|
40
|
+
// In the order the outline shows them.
|
|
41
|
+
return {
|
|
42
|
+
disabled: isDisabled(element),
|
|
43
|
+
checked: checkable
|
|
44
|
+
? (isInput(element) && element.checked) || element.getAttribute("aria-checked") === "true"
|
|
45
|
+
: undefined,
|
|
46
|
+
expanded: given("aria-expanded"),
|
|
47
|
+
selected: given("aria-selected"),
|
|
48
|
+
pressed: given("aria-pressed"),
|
|
49
|
+
focused: element.ownerDocument.activeElement === element,
|
|
50
|
+
level:
|
|
51
|
+
role === "heading"
|
|
52
|
+
? Number(element.getAttribute("aria-level")) ||
|
|
53
|
+
Number(/^H([1-6])$/.exec(element.tagName)?.[1]) ||
|
|
54
|
+
2
|
|
55
|
+
: undefined,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The elements in scope that match a query, in tree order. Without text, an element matches
|
|
61
|
+
* when it has a role or is a control. With text, any element can, but text inside a control or
|
|
62
|
+
* heading is that control's or heading's, and an element is left out when one inside it
|
|
63
|
+
* matches too: the smallest element showing the text is found, not each one around it.
|
|
64
|
+
*/
|
|
65
|
+
const matched = (request: FindRequest) => {
|
|
66
|
+
const { at } = request;
|
|
67
|
+
const matches = matching.compile(request);
|
|
68
|
+
const byText = request.text !== null;
|
|
69
|
+
const viewport = request.scope === "viewport";
|
|
70
|
+
const read = known();
|
|
71
|
+
|
|
72
|
+
interface Entry {
|
|
73
|
+
readonly element: Element;
|
|
74
|
+
readonly rect: DOMRect;
|
|
75
|
+
readonly role: string | null;
|
|
76
|
+
name?: string;
|
|
77
|
+
context?: SubjectContext;
|
|
78
|
+
matched: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// An element the walk is inside: its entry if it may match, whether text flows into it
|
|
82
|
+
// without a break, the text read in it so far, and whether something in it matched.
|
|
83
|
+
interface Open {
|
|
84
|
+
readonly entry: Entry | undefined;
|
|
85
|
+
readonly inline: boolean;
|
|
86
|
+
readonly parts: Array<string>;
|
|
87
|
+
inside: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entries: Array<Entry> = [];
|
|
91
|
+
const open: Array<Open> = [];
|
|
92
|
+
|
|
93
|
+
const named = (entry: Entry) => (entry.name ??= nameOf(entry.element, entry.role));
|
|
94
|
+
const placed = (entry: Entry) => (entry.context ??= contextOf(entry.element, read));
|
|
95
|
+
|
|
96
|
+
const judge = (entry: Entry, text: () => string) =>
|
|
97
|
+
matches({ role: entry.role, name: () => named(entry), text, context: () => placed(entry) });
|
|
98
|
+
|
|
99
|
+
// At a point, the one element a point action there would reach, if it meets the other rules.
|
|
100
|
+
const hit = at === null ? null : hitAt(document, at.x, at.y);
|
|
101
|
+
|
|
102
|
+
if (hit !== null) {
|
|
103
|
+
const element = controlOf(hit);
|
|
104
|
+
const { x, y, width, height } = boxOf(element);
|
|
105
|
+
const rect = new DOMRect(x, y, width, height);
|
|
106
|
+
const entry: Entry = { element, rect, role: roleOf(element), matched: false };
|
|
107
|
+
|
|
108
|
+
entry.matched = judge(entry, () => textOf(element));
|
|
109
|
+
entries.push(entry);
|
|
110
|
+
}
|
|
111
|
+
if (at === null)
|
|
112
|
+
walked.visit(
|
|
113
|
+
null,
|
|
114
|
+
viewport,
|
|
115
|
+
{ pointer: false, owned: false },
|
|
116
|
+
{
|
|
117
|
+
enter: (element, style, rect, state) => {
|
|
118
|
+
const role = roleOf(element);
|
|
119
|
+
const control = isControl(element, role, style, rect, state.pointer);
|
|
120
|
+
const unit = control || role === "heading";
|
|
121
|
+
const candidate = byText ? unit || !state.owned : role !== null || control;
|
|
122
|
+
|
|
123
|
+
const entry =
|
|
124
|
+
candidate && (!viewport || inView(rect))
|
|
125
|
+
? { element, rect, role, matched: false }
|
|
126
|
+
: undefined;
|
|
127
|
+
|
|
128
|
+
if (entry !== undefined) entries.push(entry);
|
|
129
|
+
open.push({ entry, inline: style.display === "inline", parts: [], inside: false });
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
pointer: state.pointer || style.cursor === "pointer",
|
|
133
|
+
owned: state.owned || unit,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
text: byText ? (node) => open.at(-1)?.parts.push(node.textContent ?? "") : undefined,
|
|
137
|
+
leave: () => {
|
|
138
|
+
const closed = open.pop();
|
|
139
|
+
const parent = open.at(-1);
|
|
140
|
+
|
|
141
|
+
if (closed === undefined) return;
|
|
142
|
+
const { entry } = closed;
|
|
143
|
+
const text = closed.parts.join("");
|
|
144
|
+
|
|
145
|
+
if (parent !== undefined) parent.parts.push(closed.inline ? text : ` ${text} `);
|
|
146
|
+
if (entry !== undefined && !(byText && closed.inside))
|
|
147
|
+
entry.matched = judge(entry, () => text);
|
|
148
|
+
if (parent !== undefined) parent.inside ||= closed.inside || entry?.matched === true;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return { entries: entries.filter((entry) => entry.matched), named, placed };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const find = (request: FindRequest) => {
|
|
157
|
+
if (refs.next < request.firstRef) refs.next = request.firstRef;
|
|
158
|
+
const { entries, named, placed } = matched(request);
|
|
159
|
+
|
|
160
|
+
const found = entries.map((entry) => ({
|
|
161
|
+
ref: refFor(entry.element),
|
|
162
|
+
subject: {
|
|
163
|
+
role: entry.role,
|
|
164
|
+
name: named(entry),
|
|
165
|
+
tag: entry.element.tagName.toLowerCase(),
|
|
166
|
+
context: placed(entry),
|
|
167
|
+
},
|
|
168
|
+
box: {
|
|
169
|
+
x: Math.round(entry.rect.x),
|
|
170
|
+
y: Math.round(entry.rect.y),
|
|
171
|
+
width: Math.round(entry.rect.width),
|
|
172
|
+
height: Math.round(entry.rect.height),
|
|
173
|
+
},
|
|
174
|
+
inViewport: inView(entry.rect),
|
|
175
|
+
state: stateOf(entry.element, entry.role),
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
return { found, nextRef: refs.next };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The elements `find` would find, in tree order, with no refs given. */
|
|
182
|
+
const elementsOf = (request: FindRequest) =>
|
|
183
|
+
matched(request).entries.map((entry) => entry.element);
|
|
184
|
+
|
|
185
|
+
return { find, elementsOf, isControl, stateOf };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export type Subjects = ReturnType<typeof subjects>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: the text a page shows, and the one rule for what a field shows. A field's value
|
|
3
|
+
* reads `••••` unless a caller unmasks it, and a secret field's always does, including a field
|
|
4
|
+
* that was secret when the library saw it, such as a password its page now reveals. Text, and the
|
|
5
|
+
* outline, are cut at a line. See `names.inpage.ts` for what a page-side part may use.
|
|
6
|
+
*/
|
|
7
|
+
import { Schema } from "effect";
|
|
8
|
+
|
|
9
|
+
import type { Names } from "./names.inpage.ts";
|
|
10
|
+
import type { Walk } from "./walk.inpage.ts";
|
|
11
|
+
|
|
12
|
+
export interface TextRequest {
|
|
13
|
+
/** An element to read whole, or null for what the viewport shows. */
|
|
14
|
+
readonly ref: string | null;
|
|
15
|
+
readonly maxChars: number;
|
|
16
|
+
readonly unmask: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What `read` sends back, or null when its ref no longer names an element. */
|
|
20
|
+
export const TextResultSchema = Schema.NullOr(
|
|
21
|
+
Schema.Struct({
|
|
22
|
+
text: Schema.String,
|
|
23
|
+
truncated: Schema.Boolean,
|
|
24
|
+
url: Schema.String,
|
|
25
|
+
title: Schema.String,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export type TextResult = typeof TextResultSchema.Type;
|
|
30
|
+
|
|
31
|
+
export const text = (names: Names, walked: Walk) => {
|
|
32
|
+
const { isHtml, isInput, isSecret, isSelect, isTextArea, lookup } = names;
|
|
33
|
+
|
|
34
|
+
// Input types whose value is a label or a choice rather than something entered.
|
|
35
|
+
const valueless = ["checkbox", "radio", "button", "submit", "reset", "image", "file"];
|
|
36
|
+
|
|
37
|
+
/** What a field holds, or undefined for an element that holds nothing entered. */
|
|
38
|
+
const valueOf = (element: Element): string | undefined => {
|
|
39
|
+
if (isInput(element)) return valueless.includes(element.type) ? undefined : element.value;
|
|
40
|
+
if (isTextArea(element)) return element.value;
|
|
41
|
+
if (isSelect(element))
|
|
42
|
+
return Array.from(element.selectedOptions, (option) => option.text).join(", ");
|
|
43
|
+
const parent = element.parentElement;
|
|
44
|
+
|
|
45
|
+
// An editable region's own root holds what was typed into it.
|
|
46
|
+
return isHtml(element) &&
|
|
47
|
+
element.isContentEditable &&
|
|
48
|
+
!(parent !== null && isHtml(parent) && parent.isContentEditable)
|
|
49
|
+
? element.innerText
|
|
50
|
+
: undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** What a field shows: `••••` unless `unmask`, and always for a secret field; "" when empty. */
|
|
54
|
+
const shown = (element: Element, unmask: boolean): string | undefined => {
|
|
55
|
+
const value = valueOf(element);
|
|
56
|
+
|
|
57
|
+
if (value === undefined || value.trim() === "") return value === undefined ? undefined : "";
|
|
58
|
+
|
|
59
|
+
return unmask && !isSecret(element) ? value : "••••";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Lines cut after the last whole line within `max` characters, or at `max` when none is. */
|
|
63
|
+
const cut = (lines: string, max: number) => {
|
|
64
|
+
if (lines.length <= max) return { text: lines, truncated: false };
|
|
65
|
+
const end = lines.lastIndexOf("\n", max);
|
|
66
|
+
|
|
67
|
+
return { text: lines.slice(0, end > 0 ? end : max), truncated: true };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the viewport, or one element whole, shows as text: a line per block, table cells apart by
|
|
72
|
+
* a tab, and each field as `shown` gives it.
|
|
73
|
+
*/
|
|
74
|
+
const lines = (root: Element | null, unmask: boolean): string => {
|
|
75
|
+
const lines: Array<string> = [];
|
|
76
|
+
let line = "";
|
|
77
|
+
|
|
78
|
+
const end = () => {
|
|
79
|
+
const words = line
|
|
80
|
+
.replace(/[^\S\t]+/g, " ")
|
|
81
|
+
.replace(/ ?\t ?/g, "\t")
|
|
82
|
+
.trim();
|
|
83
|
+
|
|
84
|
+
if (words !== "") lines.push(words);
|
|
85
|
+
line = "";
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
walked.visit(root, root === null, false, {
|
|
89
|
+
enter: (element, style) => {
|
|
90
|
+
const { display } = style;
|
|
91
|
+
const value = shown(element, unmask);
|
|
92
|
+
|
|
93
|
+
const block =
|
|
94
|
+
!display.startsWith("inline") && display !== "contents" && display !== "table-cell";
|
|
95
|
+
|
|
96
|
+
if (display === "table-cell") line += "\t";
|
|
97
|
+
if (block || element.tagName === "BR") end();
|
|
98
|
+
if (value === undefined && !(isInput(element) && valueless.includes(element.type)))
|
|
99
|
+
return block;
|
|
100
|
+
// A button's value is its label; a box or a choice shows no text of its own.
|
|
101
|
+
const label = isInput(element) && /^(button|submit|reset)$/.test(element.type);
|
|
102
|
+
|
|
103
|
+
line += ` ${value ?? (label ? element.value : "")} `;
|
|
104
|
+
if (block) end();
|
|
105
|
+
|
|
106
|
+
return undefined;
|
|
107
|
+
},
|
|
108
|
+
text: (node) => {
|
|
109
|
+
line += node.textContent ?? "";
|
|
110
|
+
},
|
|
111
|
+
leave: (_, block) => {
|
|
112
|
+
if (block) end();
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
end();
|
|
116
|
+
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const read = (request: TextRequest): TextResult => {
|
|
121
|
+
const root = request.ref === null ? null : lookup(request.ref);
|
|
122
|
+
|
|
123
|
+
return root === undefined
|
|
124
|
+
? null
|
|
125
|
+
: {
|
|
126
|
+
...cut(lines(root, request.unmask), request.maxChars),
|
|
127
|
+
url: location.href,
|
|
128
|
+
title: document.title,
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return { cut, lines, read, shown };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type Texts = ReturnType<typeof text>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: the walk every read shares, what is in view and shows, and what is painted at a
|
|
3
|
+
* point. The walk visits visible elements in tree order, into shadow roots and same-origin frames.
|
|
4
|
+
* Reading the viewport, it skips a subtree whose box lies outside the viewport before it styles
|
|
5
|
+
* anything in it, which spares a long table all but its rows in view. A box says nothing of what
|
|
6
|
+
* is positioned out of it, so a subtree is kept when its box is empty, such as a wrapper of a
|
|
7
|
+
* pinned dialog, when it holds what is painted at the viewport's edges, corners or middle, under
|
|
8
|
+
* any transparent layer too, or in its top layer, or when it holds what its style attribute pins
|
|
9
|
+
* in view. Something a stylesheet pins inside a subtree out of view, away from those points or
|
|
10
|
+
* ignoring the pointer, is missed. See `names.inpage.ts` for what a page-side part may use.
|
|
11
|
+
*/
|
|
12
|
+
import type { Names } from "./names.inpage.ts";
|
|
13
|
+
|
|
14
|
+
export interface Visitor<S> {
|
|
15
|
+
/** The state for the element's children, or undefined to leave them out. */
|
|
16
|
+
readonly enter: (
|
|
17
|
+
element: Element,
|
|
18
|
+
style: CSSStyleDeclaration,
|
|
19
|
+
rect: DOMRect,
|
|
20
|
+
state: S,
|
|
21
|
+
) => S | undefined;
|
|
22
|
+
/** Whether to visit a child of an element entered with `state`; all by default. */
|
|
23
|
+
readonly admit?: ((child: Element, state: S) => boolean) | undefined;
|
|
24
|
+
/** Each text node of an entered element, in order among its children. */
|
|
25
|
+
readonly text?: ((node: Node, state: S) => void) | undefined;
|
|
26
|
+
/** After the children of an element `enter` went into, with the state it returned. */
|
|
27
|
+
readonly leave?: ((element: Element, state: S) => void) | undefined;
|
|
28
|
+
/** A subtree left out because its box lies outside the viewport, in top-document pixels. */
|
|
29
|
+
readonly outside?: ((rect: DOMRect) => void) | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const walk = (names: Names) => {
|
|
33
|
+
const { interactiveRoles, isElement, isFrame, parentOf, roleOf } = names;
|
|
34
|
+
|
|
35
|
+
const skipped = new Set("SCRIPT STYLE NOSCRIPT TEMPLATE HEAD META LINK SVG".split(" "));
|
|
36
|
+
|
|
37
|
+
/** Whether a box, in top-document pixels, meets the viewport. */
|
|
38
|
+
const inView = (rect: DOMRect, width = innerWidth, height = innerHeight): boolean =>
|
|
39
|
+
rect.right > 0 && rect.bottom > 0 && rect.left < width && rect.top < height;
|
|
40
|
+
|
|
41
|
+
/** Whether an element reached on its own shows, by the walk's rule and its ancestors'. */
|
|
42
|
+
const visible = (element: Element): boolean =>
|
|
43
|
+
element.checkVisibility({ opacityProperty: true, visibilityProperty: true }) &&
|
|
44
|
+
element.closest("[aria-hidden=true]") === null;
|
|
45
|
+
|
|
46
|
+
const isVisible = (element: Element, style: CSSStyleDeclaration, rect: DOMRect): boolean =>
|
|
47
|
+
style.display !== "none" &&
|
|
48
|
+
style.visibility === "visible" &&
|
|
49
|
+
style.opacity !== "0" &&
|
|
50
|
+
element.getAttribute("aria-hidden") !== "true" &&
|
|
51
|
+
(rect.width > 0 || rect.height > 0 || style.display === "contents");
|
|
52
|
+
|
|
53
|
+
// Coordinate subtraction is valid only for an untransformed frame. Report the frame itself
|
|
54
|
+
// otherwise: the real pixel input still works, and its receipt must not name a guessed child.
|
|
55
|
+
const untransformed = (frame: HTMLIFrameElement, rect: DOMRect): boolean => {
|
|
56
|
+
if (rect.width !== frame.offsetWidth || rect.height !== frame.offsetHeight) return false;
|
|
57
|
+
let ancestor: Element | null = frame;
|
|
58
|
+
|
|
59
|
+
while (ancestor !== null) {
|
|
60
|
+
const view = ancestor.ownerDocument.defaultView ?? window;
|
|
61
|
+
const style = view.getComputedStyle(ancestor);
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
style.transform !== "none" ||
|
|
65
|
+
style.scale !== "none" ||
|
|
66
|
+
style.rotate !== "none" ||
|
|
67
|
+
style.perspective !== "none" ||
|
|
68
|
+
style.zoom !== "1"
|
|
69
|
+
)
|
|
70
|
+
return false;
|
|
71
|
+
ancestor = parentOf(ancestor);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The element painted at a point, inside shadow roots and same-origin frames. */
|
|
78
|
+
const hitAt = (root: Document | ShadowRoot, x: number, y: number): Element | null => {
|
|
79
|
+
let hit = root.elementFromPoint(x, y);
|
|
80
|
+
|
|
81
|
+
while (hit?.shadowRoot !== null && hit?.shadowRoot !== undefined) {
|
|
82
|
+
const inner = hit.shadowRoot.elementFromPoint(x, y);
|
|
83
|
+
|
|
84
|
+
if (inner === null || inner === hit) break;
|
|
85
|
+
hit = inner;
|
|
86
|
+
}
|
|
87
|
+
if (hit !== null && isFrame(hit)) {
|
|
88
|
+
const document = hit.contentDocument;
|
|
89
|
+
|
|
90
|
+
if (document !== null) {
|
|
91
|
+
const rect = hit.getBoundingClientRect();
|
|
92
|
+
|
|
93
|
+
if (untransformed(hit, rect))
|
|
94
|
+
return (
|
|
95
|
+
hitAt(document, x - rect.left - hit.clientLeft, y - rect.top - hit.clientTop) ?? hit
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return hit;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Whether its role or a click handler makes an element a control. */
|
|
104
|
+
const acts = (element: Element, role: string | null): boolean =>
|
|
105
|
+
(role !== null && (interactiveRoles.has(role) || role === "canvas" || role === "iframe")) ||
|
|
106
|
+
element.hasAttribute("onclick");
|
|
107
|
+
|
|
108
|
+
// A painted child of a control still activates the control; keep its name without moving the point.
|
|
109
|
+
const controlOf = (hit: Element): Element => {
|
|
110
|
+
let element: Element | null = hit;
|
|
111
|
+
|
|
112
|
+
while (element !== null) {
|
|
113
|
+
if (acts(element, roleOf(element))) return element;
|
|
114
|
+
element = parentOf(element);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return hit;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** An element's box in top-document viewport pixels, through the frames around it. */
|
|
121
|
+
const boxOf = (element: Element): { x: number; y: number; width: number; height: number } => {
|
|
122
|
+
const { x, y, width, height } = element.getBoundingClientRect();
|
|
123
|
+
const frame = element.ownerDocument.defaultView?.frameElement ?? null;
|
|
124
|
+
|
|
125
|
+
if (frame === null) return { x, y, width, height };
|
|
126
|
+
const outer = boxOf(frame);
|
|
127
|
+
|
|
128
|
+
return { x: x + outer.x + frame.clientLeft, y: y + outer.y + frame.clientTop, width, height };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** What is painted where pinned things sit, what is pinned in view, and every element holding it. */
|
|
132
|
+
const painted = (width: number, height: number): Set<Element> => {
|
|
133
|
+
const keep = new Set<Element>();
|
|
134
|
+
|
|
135
|
+
const hold = (element: Element | null) => {
|
|
136
|
+
for (
|
|
137
|
+
let node = element;
|
|
138
|
+
node !== null && !keep.has(node);
|
|
139
|
+
node = parentOf(node) ?? node.ownerDocument.defaultView?.frameElement ?? null
|
|
140
|
+
)
|
|
141
|
+
keep.add(node);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const y of [8, 48, height / 2, height - 48, height - 8])
|
|
145
|
+
for (const x of [8, 48, width / 4, width / 2, (width * 3) / 4, width - 48, width - 8])
|
|
146
|
+
if (x >= 0 && y >= 0 && x < width && y < height) {
|
|
147
|
+
hold(hitAt(document, x, y));
|
|
148
|
+
// What a transparent layer, such as one over a video player, covers.
|
|
149
|
+
for (const beneath of document.elementsFromPoint(x, y)) hold(beneath);
|
|
150
|
+
}
|
|
151
|
+
for (const element of Array.from(document.querySelectorAll(":modal, :popover-open")))
|
|
152
|
+
hold(element);
|
|
153
|
+
// What a style attribute pins, wherever it sits; a stylesheet's pins only at the points.
|
|
154
|
+
for (const element of Array.from(document.querySelectorAll('[style*="fixed" i]')))
|
|
155
|
+
if (inView(element.getBoundingClientRect(), width, height)) hold(element);
|
|
156
|
+
|
|
157
|
+
return keep;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Visit `root` and what it holds, or the whole document when `root` is null. With `viewport`,
|
|
162
|
+
* a subtree outside the viewport is skipped, as described above.
|
|
163
|
+
*/
|
|
164
|
+
const visit = <S>(root: Element | null, viewport: boolean, state: S, visitor: Visitor<S>) => {
|
|
165
|
+
const width = window.innerWidth;
|
|
166
|
+
const height = window.innerHeight;
|
|
167
|
+
let keep: Set<Element> | undefined;
|
|
168
|
+
|
|
169
|
+
const children = (parent: ParentNode, inner: S, dx: number, dy: number) => {
|
|
170
|
+
// Text nodes are touched only for a visitor that reads them.
|
|
171
|
+
const nodes = visitor.text === undefined ? parent.children : parent.childNodes;
|
|
172
|
+
|
|
173
|
+
for (const node of Array.from<Node>(nodes)) {
|
|
174
|
+
if (node.nodeType === Node.TEXT_NODE) visitor.text?.(node, inner);
|
|
175
|
+
else if (isElement(node) && (visitor.admit === undefined || visitor.admit(node, inner)))
|
|
176
|
+
one(node, inner, dx, dy);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const one = (element: Element, outer: S, dx: number, dy: number): void => {
|
|
181
|
+
if (skipped.has(element.tagName.toUpperCase())) return;
|
|
182
|
+
const local = element.getBoundingClientRect();
|
|
183
|
+
const rect = new DOMRect(local.x + dx, local.y + dy, local.width, local.height);
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
viewport &&
|
|
187
|
+
local.width > 0 &&
|
|
188
|
+
local.height > 0 &&
|
|
189
|
+
!inView(rect, width, height) &&
|
|
190
|
+
!(keep ??= painted(width, height)).has(element)
|
|
191
|
+
) {
|
|
192
|
+
visitor.outside?.(rect);
|
|
193
|
+
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const style = getComputedStyle(element);
|
|
197
|
+
|
|
198
|
+
if (!isVisible(element, style, local)) return;
|
|
199
|
+
const inner = visitor.enter(element, style, rect, outer);
|
|
200
|
+
|
|
201
|
+
if (inner === undefined) return;
|
|
202
|
+
if (isFrame(element)) {
|
|
203
|
+
const body = element.contentDocument?.body;
|
|
204
|
+
|
|
205
|
+
if (body !== null && body !== undefined) children(body, inner, rect.x, rect.y);
|
|
206
|
+
} else {
|
|
207
|
+
if (element.shadowRoot !== null) children(element.shadowRoot, inner, dx, dy);
|
|
208
|
+
children(element, inner, dx, dy);
|
|
209
|
+
}
|
|
210
|
+
visitor.leave?.(element, inner);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
if (root === null) children(document, state, 0, 0);
|
|
214
|
+
else one(root, state, 0, 0);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return { acts, boxOf, controlOf, hitAt, inView, visible, visit };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export type Walk = ReturnType<typeof walk>;
|