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,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In the page: what the input guard decides on. It inspects an input's targets for the facts a
|
|
3
|
+
* policy weighs, such as a form submission or a secret field, binds an approval to them, and
|
|
4
|
+
* checks before each press that the approved target would still receive it. See `reading/names.inpage.ts` for what a page-side part may use.
|
|
5
|
+
*/
|
|
6
|
+
import { Schema } from "effect";
|
|
7
|
+
|
|
8
|
+
import type { Names } from "../reading/names.inpage.ts";
|
|
9
|
+
import { type Evidence, type EvidenceReader, EvidenceSchema } from "./evidence.inpage.ts";
|
|
10
|
+
import type { Targets } from "./targets.inpage.ts";
|
|
11
|
+
|
|
12
|
+
export const Fact = Schema.Literals([
|
|
13
|
+
"form-submit",
|
|
14
|
+
"cross-origin",
|
|
15
|
+
"download",
|
|
16
|
+
"upload",
|
|
17
|
+
"secret",
|
|
18
|
+
"scripted",
|
|
19
|
+
"opaque",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export type Fact = typeof Fact.Type;
|
|
23
|
+
|
|
24
|
+
export interface InputPlan {
|
|
25
|
+
readonly action: string;
|
|
26
|
+
readonly targets: ReadonlyArray<string | { readonly x: number; readonly y: number } | null>;
|
|
27
|
+
readonly submit: boolean;
|
|
28
|
+
readonly keys: string | null;
|
|
29
|
+
readonly destination: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InspectedTarget {
|
|
33
|
+
readonly ref: string;
|
|
34
|
+
readonly element: string;
|
|
35
|
+
readonly role: string | null;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly cursor: string;
|
|
38
|
+
readonly href?: string | undefined;
|
|
39
|
+
/** A field the DOM marks as holding a password, a one-time code or a card's details. */
|
|
40
|
+
readonly secret: boolean;
|
|
41
|
+
readonly fingerprint: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PreparedInput {
|
|
45
|
+
readonly url: string;
|
|
46
|
+
readonly targets: ReadonlyArray<InspectedTarget | null>;
|
|
47
|
+
readonly facts: ReadonlyArray<Fact>;
|
|
48
|
+
readonly destination?: string | undefined;
|
|
49
|
+
readonly evidence: Evidence;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A refusal; a stale target is named by its index in the plan. */
|
|
53
|
+
export interface InputFailure {
|
|
54
|
+
readonly error: string;
|
|
55
|
+
readonly detail: string;
|
|
56
|
+
readonly index?: number | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type PreparedInputResult = PreparedInput | InputFailure;
|
|
60
|
+
|
|
61
|
+
export type ValidatedInputResult = { readonly ok: true } | InputFailure;
|
|
62
|
+
|
|
63
|
+
/** A point where a target, by its index in the plan, is about to receive a press. */
|
|
64
|
+
export interface Press {
|
|
65
|
+
readonly index: number;
|
|
66
|
+
readonly x: number;
|
|
67
|
+
readonly y: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ValidationOptions {
|
|
71
|
+
/** Require focus on the first target. */
|
|
72
|
+
readonly focused?: boolean;
|
|
73
|
+
/** After the pointer's own events have run, each target must still receive its press. */
|
|
74
|
+
readonly presses?: ReadonlyArray<Press>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const guard = (names: Names, targets: Targets, evidence: EvidenceReader) => {
|
|
78
|
+
const {
|
|
79
|
+
activeElement,
|
|
80
|
+
clean,
|
|
81
|
+
describe,
|
|
82
|
+
inCurrentDocument,
|
|
83
|
+
interactiveRoles,
|
|
84
|
+
isButton,
|
|
85
|
+
isDisabled,
|
|
86
|
+
isHtml,
|
|
87
|
+
isInput,
|
|
88
|
+
isLabel,
|
|
89
|
+
isRoot,
|
|
90
|
+
isSecret,
|
|
91
|
+
isSelect,
|
|
92
|
+
isTextArea,
|
|
93
|
+
lookup,
|
|
94
|
+
parentOf,
|
|
95
|
+
refFor,
|
|
96
|
+
roleOf,
|
|
97
|
+
} = names;
|
|
98
|
+
|
|
99
|
+
const { controlOf, details, hitAt, hrefAttribute, linkOf, receives, within } = targets;
|
|
100
|
+
const { evidenceOf, isField, isFilled } = evidence;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The element whose activation behaviour a click or key on `element` runs, as the browser
|
|
104
|
+
* resolves it along the composed path: a link, button, input, summary or a label's control.
|
|
105
|
+
*/
|
|
106
|
+
const activationTarget = (element: Element): Element | undefined => {
|
|
107
|
+
for (let node: Element | null = element; node !== null; node = parentOf(node)) {
|
|
108
|
+
if (
|
|
109
|
+
hrefAttribute(node) !== null ||
|
|
110
|
+
node.tagName === "BUTTON" ||
|
|
111
|
+
node.tagName === "SUMMARY" ||
|
|
112
|
+
(isInput(node) && node.type !== "hidden")
|
|
113
|
+
)
|
|
114
|
+
return node;
|
|
115
|
+
if (isLabel(node) && node.control !== null) return node.control;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A control between a press's hit and the approved target that is not the target's own
|
|
123
|
+
* activation, such as a link or button a hover handler nested into it. The approval inspected
|
|
124
|
+
* the target, so that control would act unapproved.
|
|
125
|
+
*/
|
|
126
|
+
const nestedControl = (element: Element, hit: Element): Element | undefined => {
|
|
127
|
+
// A container of the target is covered by the target's own facts.
|
|
128
|
+
if (!within(element, hit)) return undefined;
|
|
129
|
+
const own = activationTarget(element);
|
|
130
|
+
|
|
131
|
+
for (
|
|
132
|
+
let node: Element | null = hit;
|
|
133
|
+
node !== null && node !== element;
|
|
134
|
+
node = parentOf(node) ?? node.ownerDocument.defaultView?.frameElement ?? null
|
|
135
|
+
)
|
|
136
|
+
if (
|
|
137
|
+
node !== own &&
|
|
138
|
+
(hrefAttribute(node) !== null ||
|
|
139
|
+
node.tagName === "BUTTON" ||
|
|
140
|
+
node.tagName === "SUMMARY" ||
|
|
141
|
+
(isInput(node) && node.type !== "hidden") ||
|
|
142
|
+
isSelect(node) ||
|
|
143
|
+
isTextArea(node) ||
|
|
144
|
+
(isLabel(node) && node.control !== null && node.control !== own) ||
|
|
145
|
+
activatingRoles.has(roleOf(node) ?? ""))
|
|
146
|
+
)
|
|
147
|
+
return node;
|
|
148
|
+
|
|
149
|
+
return undefined;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const textEntry = (element: Element): boolean =>
|
|
153
|
+
(isInput(element) &&
|
|
154
|
+
![
|
|
155
|
+
"button",
|
|
156
|
+
"reset",
|
|
157
|
+
"submit",
|
|
158
|
+
"image",
|
|
159
|
+
"checkbox",
|
|
160
|
+
"radio",
|
|
161
|
+
"file",
|
|
162
|
+
"range",
|
|
163
|
+
"color",
|
|
164
|
+
"hidden",
|
|
165
|
+
].includes(element.type)) ||
|
|
166
|
+
isTextArea(element) ||
|
|
167
|
+
(isHtml(element) && element.isContentEditable);
|
|
168
|
+
|
|
169
|
+
const activatingRoles = new Set([
|
|
170
|
+
"button",
|
|
171
|
+
"link",
|
|
172
|
+
"checkbox",
|
|
173
|
+
"radio",
|
|
174
|
+
"switch",
|
|
175
|
+
"tab",
|
|
176
|
+
"menuitem",
|
|
177
|
+
"menuitemcheckbox",
|
|
178
|
+
"menuitemradio",
|
|
179
|
+
"option",
|
|
180
|
+
"treeitem",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
// Typed text goes wherever focus is, and a typed space or letter can press a focused button,
|
|
184
|
+
// toggle a box, follow a link or change a select. Only fields, or elements that are none of
|
|
185
|
+
// these (a page or a canvas game), may receive text.
|
|
186
|
+
const typingRefusal = (element: Element, explicit: boolean): string | undefined =>
|
|
187
|
+
textEntry(element)
|
|
188
|
+
? undefined
|
|
189
|
+
: explicit
|
|
190
|
+
? `${describe(element)} is not a text field`
|
|
191
|
+
: activationTarget(element) !== undefined ||
|
|
192
|
+
isSelect(element) ||
|
|
193
|
+
activatingRoles.has(roleOf(element) ?? "")
|
|
194
|
+
? `focus is on ${describe(element)}, which typed text could activate or change; type into a text field by ref, or use press for keys`
|
|
195
|
+
: undefined;
|
|
196
|
+
|
|
197
|
+
const isSubmitter = (element: Element): element is HTMLButtonElement | HTMLInputElement =>
|
|
198
|
+
(isButton(element) && element.type === "submit") ||
|
|
199
|
+
(isInput(element) && (element.type === "submit" || element.type === "image"));
|
|
200
|
+
|
|
201
|
+
// form.elements omits image submitters. Walk the whole root in tree order so an Enter
|
|
202
|
+
// submission uses the real default button, including controls associated from outside the form.
|
|
203
|
+
const defaultSubmitter = (form: HTMLFormElement) => {
|
|
204
|
+
const root = form.getRootNode();
|
|
205
|
+
|
|
206
|
+
return isRoot(root)
|
|
207
|
+
? Array.from(root.querySelectorAll("button,input")).find(
|
|
208
|
+
(candidate): candidate is HTMLButtonElement | HTMLInputElement =>
|
|
209
|
+
isSubmitter(candidate) && candidate.form === form,
|
|
210
|
+
)
|
|
211
|
+
: undefined;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Attributes that say what a field is for. They bind an approval.
|
|
215
|
+
const purpose = (control: Element, form: HTMLFormElement | null) => [
|
|
216
|
+
control.getAttribute("id"),
|
|
217
|
+
control.getAttribute("name"),
|
|
218
|
+
control.getAttribute("inputmode"),
|
|
219
|
+
control.getAttribute("autocomplete"),
|
|
220
|
+
control.getAttribute("aria-label"),
|
|
221
|
+
form?.getAttribute("id"),
|
|
222
|
+
form?.getAttribute("name"),
|
|
223
|
+
form?.getAttribute("aria-label"),
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
// What an approval binds of a URL. A fragment that names a place on the page, which scroll-spy
|
|
227
|
+
// and feed pages rewrite as they scroll, is left out. A hash route (#/… or #!…) stays: it
|
|
228
|
+
// selects what the page's controls act on.
|
|
229
|
+
const boundUrl = (url: string): string => {
|
|
230
|
+
const hash = url.indexOf("#");
|
|
231
|
+
|
|
232
|
+
return hash === -1 || /^#[/!]/.test(url.slice(hash)) ? url : url.slice(0, hash);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const inspectInput = (element: Element, plan: InputPlan) => {
|
|
236
|
+
const metadata = details(element, element, 0, 0);
|
|
237
|
+
// Facts are about what the input activates, such as the submit button around a painted label.
|
|
238
|
+
const control = activationTarget(element) ?? element;
|
|
239
|
+
|
|
240
|
+
const form =
|
|
241
|
+
isInput(control) || isButton(control) || isTextArea(control) || isSelect(control)
|
|
242
|
+
? control.form
|
|
243
|
+
: null;
|
|
244
|
+
|
|
245
|
+
// The chord's last key, which may be the plus key itself: Control++.
|
|
246
|
+
const key = plan.keys === null ? undefined : /(?:^|\+)(\+|[^+]+)$/.exec(plan.keys)?.[1];
|
|
247
|
+
const enter = key === "Enter";
|
|
248
|
+
|
|
249
|
+
const activation =
|
|
250
|
+
plan.action === "click" || (plan.action === "press" && (enter || key === "Space"));
|
|
251
|
+
|
|
252
|
+
// Enter submits a form from any input but buttons, file and color pickers, including
|
|
253
|
+
// checkboxes, radios and ranges.
|
|
254
|
+
const fieldEnter =
|
|
255
|
+
isInput(control) &&
|
|
256
|
+
!["button", "reset", "submit", "image", "file", "color", "hidden"].includes(control.type) &&
|
|
257
|
+
((plan.action === "press" && enter) || (plan.action === "type" && plan.submit));
|
|
258
|
+
|
|
259
|
+
const submits = form !== null && ((activation && isSubmitter(control)) || fieldEnter);
|
|
260
|
+
|
|
261
|
+
const submitter = isSubmitter(control)
|
|
262
|
+
? control
|
|
263
|
+
: form === null
|
|
264
|
+
? undefined
|
|
265
|
+
: defaultSubmitter(form);
|
|
266
|
+
|
|
267
|
+
const formDestination =
|
|
268
|
+
form === null
|
|
269
|
+
? undefined
|
|
270
|
+
: submitter?.hasAttribute("formaction") === true
|
|
271
|
+
? submitter.formAction
|
|
272
|
+
: form.action;
|
|
273
|
+
|
|
274
|
+
const formMethod =
|
|
275
|
+
form === null
|
|
276
|
+
? undefined
|
|
277
|
+
: submitter?.hasAttribute("formmethod") === true
|
|
278
|
+
? submitter.formMethod
|
|
279
|
+
: form.method;
|
|
280
|
+
|
|
281
|
+
const formTarget =
|
|
282
|
+
form === null
|
|
283
|
+
? undefined
|
|
284
|
+
: submitter?.hasAttribute("formtarget") === true
|
|
285
|
+
? submitter.formTarget
|
|
286
|
+
: form.target;
|
|
287
|
+
|
|
288
|
+
const link = linkOf(element);
|
|
289
|
+
const href = link === undefined ? null : hrefAttribute(link);
|
|
290
|
+
// A dialog form only closes its dialog; the page's script decides what that means.
|
|
291
|
+
const sends = submits && formMethod !== "dialog";
|
|
292
|
+
// Only activation follows a link or opens a file chooser; hovering or scrolling over a
|
|
293
|
+
// control does neither.
|
|
294
|
+
const destination = sends ? formDestination : activation ? metadata.href : undefined;
|
|
295
|
+
const acts = plan.action !== "hover" && plan.action !== "scroll";
|
|
296
|
+
|
|
297
|
+
// What the browser itself does on activation: follow a link, submit or reset a form, choose
|
|
298
|
+
// a file or a value, toggle a box or a disclosure, or focus a field. Anything else is the
|
|
299
|
+
// page's script.
|
|
300
|
+
const builtIn =
|
|
301
|
+
sends ||
|
|
302
|
+
(href !== null && !/^\s*(?:#\s*|javascript:[^]*)$/i.test(href)) ||
|
|
303
|
+
(isInput(control) && !["button", "submit", "image", "reset"].includes(control.type)) ||
|
|
304
|
+
((isInput(control) || isButton(control)) && control.type === "reset" && form !== null) ||
|
|
305
|
+
control.tagName === "SUMMARY" ||
|
|
306
|
+
control.closest("select") !== null ||
|
|
307
|
+
textEntry(control);
|
|
308
|
+
|
|
309
|
+
const facts: Array<Fact> = [];
|
|
310
|
+
|
|
311
|
+
// Facts come from structure only. What a name or the text around it says is evidence for a
|
|
312
|
+
// judge, never a fact: words change meaning with context and language.
|
|
313
|
+
if (sends) facts.push("form-submit");
|
|
314
|
+
if (activation && link?.hasAttribute("download") === true) facts.push("download");
|
|
315
|
+
if (activation && isInput(control) && control.type === "file") facts.push("upload");
|
|
316
|
+
if (
|
|
317
|
+
(plan.action === "type" && isSecret(control)) ||
|
|
318
|
+
(sends &&
|
|
319
|
+
form !== null &&
|
|
320
|
+
Array.from(form.elements).some(
|
|
321
|
+
(field) => isField(field) && isSecret(field) && isFilled(field),
|
|
322
|
+
))
|
|
323
|
+
)
|
|
324
|
+
facts.push("secret");
|
|
325
|
+
if (activation && !builtIn) facts.push("scripted");
|
|
326
|
+
if (acts && (metadata.role === "canvas" || metadata.role === "iframe" || metadata.name === ""))
|
|
327
|
+
facts.push("opaque");
|
|
328
|
+
|
|
329
|
+
// Bind what decides the consequence and the control's identity. Names bind interactive
|
|
330
|
+
// controls only: other text, such as a live price or a page's own text, may change freely.
|
|
331
|
+
const role = metadata.role;
|
|
332
|
+
|
|
333
|
+
const boundName =
|
|
334
|
+
role !== null &&
|
|
335
|
+
(interactiveRoles.has(role) || role === "canvas" || role === "iframe") &&
|
|
336
|
+
!(isHtml(element) && element.isContentEditable)
|
|
337
|
+
? metadata.name
|
|
338
|
+
: null;
|
|
339
|
+
|
|
340
|
+
// A focus or scroll handler changing these attributes must not leave an old permission behind.
|
|
341
|
+
const fingerprint = JSON.stringify([
|
|
342
|
+
purpose(control, form),
|
|
343
|
+
element.tagName,
|
|
344
|
+
element.id,
|
|
345
|
+
role,
|
|
346
|
+
boundName,
|
|
347
|
+
metadata.href,
|
|
348
|
+
control === element ? null : refFor(control),
|
|
349
|
+
isInput(control) || isButton(control) ? control.type : control.tagName,
|
|
350
|
+
control.matches(":disabled") || isDisabled(control),
|
|
351
|
+
control.getAttribute("readonly"),
|
|
352
|
+
control.getAttribute("aria-readonly"),
|
|
353
|
+
isHtml(control) && control.isContentEditable,
|
|
354
|
+
control.getAttribute("name"),
|
|
355
|
+
control.getAttribute("accept"),
|
|
356
|
+
control.hasAttribute("multiple"),
|
|
357
|
+
link?.getAttribute("download"),
|
|
358
|
+
form === null ? null : refFor(form),
|
|
359
|
+
form === null ? null : boundUrl(form.action),
|
|
360
|
+
form?.method,
|
|
361
|
+
form?.target,
|
|
362
|
+
submitter === undefined ? null : refFor(submitter),
|
|
363
|
+
formDestination === undefined ? undefined : boundUrl(formDestination),
|
|
364
|
+
formMethod,
|
|
365
|
+
formTarget,
|
|
366
|
+
submitter?.formNoValidate,
|
|
367
|
+
form?.noValidate,
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
const inspected: InspectedTarget = {
|
|
371
|
+
ref: refFor(element),
|
|
372
|
+
element: metadata.element,
|
|
373
|
+
role: metadata.role,
|
|
374
|
+
name: metadata.name,
|
|
375
|
+
cursor: metadata.cursor,
|
|
376
|
+
...(metadata.href === undefined ? {} : { href: metadata.href }),
|
|
377
|
+
secret: isSecret(control),
|
|
378
|
+
fingerprint,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
inspected,
|
|
383
|
+
facts,
|
|
384
|
+
destination,
|
|
385
|
+
explain: () => evidenceOf(element, form, formMethod, formDestination),
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Approval preparation only reads the DOM. In particular, an offscreen ref must not scroll
|
|
390
|
+
// before the policy has had a chance to deny it.
|
|
391
|
+
const inspect = (
|
|
392
|
+
plan: InputPlan,
|
|
393
|
+
): (Omit<PreparedInput, "evidence"> & { readonly explain: () => Evidence }) | InputFailure => {
|
|
394
|
+
const targets: Array<InspectedTarget | null> = [];
|
|
395
|
+
const facts = new Set<Fact>();
|
|
396
|
+
let destination = plan.destination ?? undefined;
|
|
397
|
+
let explain = (): Evidence => ({ title: clean(document.title, 120) });
|
|
398
|
+
|
|
399
|
+
for (const [index, target] of plan.targets.entries()) {
|
|
400
|
+
let element: Element | null | undefined;
|
|
401
|
+
|
|
402
|
+
if (target === null) {
|
|
403
|
+
element = activeElement();
|
|
404
|
+
} else if (typeof target === "string") {
|
|
405
|
+
element = lookup(target);
|
|
406
|
+
if (element === undefined)
|
|
407
|
+
return { error: "stale", detail: target + " is not on the page any more", index };
|
|
408
|
+
} else {
|
|
409
|
+
if (
|
|
410
|
+
!Number.isFinite(target.x) ||
|
|
411
|
+
!Number.isFinite(target.y) ||
|
|
412
|
+
target.x < 0 ||
|
|
413
|
+
target.y < 0 ||
|
|
414
|
+
target.x >= window.innerWidth ||
|
|
415
|
+
target.y >= window.innerHeight
|
|
416
|
+
)
|
|
417
|
+
return { error: "outside", detail: "the point is outside the viewport" };
|
|
418
|
+
const hit = hitAt(document, target.x, target.y);
|
|
419
|
+
|
|
420
|
+
element = hit === null ? null : controlOf(hit);
|
|
421
|
+
if (element === null)
|
|
422
|
+
return { error: "offscreen", detail: "nothing is painted at the point" };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (element === null) {
|
|
426
|
+
targets.push(null);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!inCurrentDocument(element))
|
|
430
|
+
return { error: "stale", detail: "the input target belongs to a replaced document", index };
|
|
431
|
+
const refusal = plan.action === "type" ? typingRefusal(element, target !== null) : undefined;
|
|
432
|
+
|
|
433
|
+
if (refusal !== undefined) return { error: "untypeable", detail: refusal };
|
|
434
|
+
const inspected = inspectInput(element, plan);
|
|
435
|
+
|
|
436
|
+
if (index === 0) explain = inspected.explain;
|
|
437
|
+
targets.push(inspected.inspected);
|
|
438
|
+
for (const fact of inspected.facts) facts.add(fact);
|
|
439
|
+
destination ??= inspected.destination;
|
|
440
|
+
if (inspected.destination !== undefined) {
|
|
441
|
+
try {
|
|
442
|
+
if (new URL(inspected.destination, location.href).origin !== location.origin)
|
|
443
|
+
facts.add("cross-origin");
|
|
444
|
+
} catch {
|
|
445
|
+
return { error: "outside", detail: "the input destination is not a valid URL" };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (destination !== undefined) {
|
|
451
|
+
try {
|
|
452
|
+
destination = new URL(destination, location.href).href;
|
|
453
|
+
if (new URL(destination).origin !== location.origin) facts.add("cross-origin");
|
|
454
|
+
} catch {
|
|
455
|
+
return { error: "outside", detail: "the input destination is not a valid URL" };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
url: boundUrl(location.href),
|
|
461
|
+
targets,
|
|
462
|
+
facts: [...facts],
|
|
463
|
+
...(destination === undefined ? {} : { destination }),
|
|
464
|
+
explain,
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Evidence is read once, for the policy. Validation binds the facts and the targets, not the
|
|
469
|
+
// text around them, which live pages change freely.
|
|
470
|
+
const prepareInput = (plan: InputPlan): PreparedInputResult => {
|
|
471
|
+
const inspected = inspect(plan);
|
|
472
|
+
|
|
473
|
+
if ("error" in inspected) return inspected;
|
|
474
|
+
const { explain, ...prepared } = inspected;
|
|
475
|
+
|
|
476
|
+
return { ...prepared, evidence: explain() };
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const validate = (
|
|
480
|
+
plan: InputPlan,
|
|
481
|
+
prepared: PreparedInput,
|
|
482
|
+
options: ValidationOptions,
|
|
483
|
+
): ValidatedInputResult => {
|
|
484
|
+
const current = inspect(plan);
|
|
485
|
+
|
|
486
|
+
if ("error" in current)
|
|
487
|
+
return current.error === "stale" ? current : { error: "changed", detail: current.detail };
|
|
488
|
+
if (
|
|
489
|
+
current.url !== prepared.url ||
|
|
490
|
+
(current.destination === undefined || prepared.destination === undefined
|
|
491
|
+
? current.destination !== prepared.destination
|
|
492
|
+
: boundUrl(current.destination) !== boundUrl(prepared.destination)) ||
|
|
493
|
+
JSON.stringify(current.facts) !== JSON.stringify(prepared.facts) ||
|
|
494
|
+
current.targets.length !== prepared.targets.length ||
|
|
495
|
+
current.targets.some((target, index) => {
|
|
496
|
+
const previous = prepared.targets[index];
|
|
497
|
+
|
|
498
|
+
return target === null
|
|
499
|
+
? previous !== null
|
|
500
|
+
: previous === null ||
|
|
501
|
+
previous === undefined ||
|
|
502
|
+
target.ref !== previous.ref ||
|
|
503
|
+
target.fingerprint !== previous.fingerprint;
|
|
504
|
+
})
|
|
505
|
+
)
|
|
506
|
+
return {
|
|
507
|
+
error: "changed",
|
|
508
|
+
detail: "the page or input target changed while the policy was deciding",
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
if (options.focused === true) {
|
|
512
|
+
const active = activeElement();
|
|
513
|
+
const expected = prepared.targets[0];
|
|
514
|
+
|
|
515
|
+
if (
|
|
516
|
+
active === null ||
|
|
517
|
+
expected === null ||
|
|
518
|
+
expected === undefined ||
|
|
519
|
+
refFor(active) !== expected.ref
|
|
520
|
+
)
|
|
521
|
+
return { error: "changed", detail: "focus moved away from the approved element" };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const press of options.presses ?? []) {
|
|
525
|
+
const expected = prepared.targets[press.index];
|
|
526
|
+
|
|
527
|
+
const element =
|
|
528
|
+
expected === null || expected === undefined ? undefined : lookup(expected.ref);
|
|
529
|
+
|
|
530
|
+
const hit = hitAt(document, press.x, press.y);
|
|
531
|
+
|
|
532
|
+
if (element === undefined || hit === null || !receives(element, hit))
|
|
533
|
+
return {
|
|
534
|
+
error: "changed",
|
|
535
|
+
detail: `the approved target is no longer under the pointer at (${press.x}, ${press.y})`,
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const nested = nestedControl(element, hit);
|
|
539
|
+
|
|
540
|
+
if (nested !== undefined)
|
|
541
|
+
return {
|
|
542
|
+
error: "changed",
|
|
543
|
+
detail: `${describe(nested)} inside the approved target would receive the press at (${press.x}, ${press.y})`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return { ok: true };
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// Chromium delivers pointer moves with the next frame. A press check waits until the frame
|
|
551
|
+
// after it, so it sees what the page did when the pointer arrived, such as a menu opened over
|
|
552
|
+
// the target. A hidden document draws no frames, and its moves wait for the press instead.
|
|
553
|
+
const validateInput = (
|
|
554
|
+
plan: InputPlan,
|
|
555
|
+
prepared: PreparedInput,
|
|
556
|
+
options: ValidationOptions = {},
|
|
557
|
+
): ValidatedInputResult | Promise<ValidatedInputResult> => {
|
|
558
|
+
if ((options.presses ?? []).length === 0) return validate(plan, prepared, options);
|
|
559
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
560
|
+
|
|
561
|
+
if (document.visibilityState === "hidden") resolve();
|
|
562
|
+
else requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
563
|
+
|
|
564
|
+
return promise.then(() => validate(plan, prepared, options));
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
return { prepareInput, textEntry, typingRefusal, validateInput };
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
export type Guard = ReturnType<typeof guard>;
|
|
571
|
+
|
|
572
|
+
const InputPreparationError = Schema.Struct({
|
|
573
|
+
error: Schema.String,
|
|
574
|
+
detail: Schema.String,
|
|
575
|
+
index: Schema.optional(Schema.Finite),
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
export const PreparedInputResultSchema = Schema.Union([
|
|
579
|
+
Schema.Struct({
|
|
580
|
+
url: Schema.String,
|
|
581
|
+
targets: Schema.Array(
|
|
582
|
+
Schema.NullOr(
|
|
583
|
+
Schema.Struct({
|
|
584
|
+
ref: Schema.String,
|
|
585
|
+
element: Schema.String,
|
|
586
|
+
role: Schema.NullOr(Schema.String),
|
|
587
|
+
name: Schema.String,
|
|
588
|
+
cursor: Schema.String,
|
|
589
|
+
href: Schema.optional(Schema.String),
|
|
590
|
+
secret: Schema.Boolean,
|
|
591
|
+
fingerprint: Schema.String,
|
|
592
|
+
}),
|
|
593
|
+
),
|
|
594
|
+
),
|
|
595
|
+
facts: Schema.Array(Fact),
|
|
596
|
+
destination: Schema.optional(Schema.String),
|
|
597
|
+
evidence: EvidenceSchema,
|
|
598
|
+
}),
|
|
599
|
+
InputPreparationError,
|
|
600
|
+
]);
|
|
601
|
+
|
|
602
|
+
export const ValidatedInputResultSchema = Schema.Union([
|
|
603
|
+
Schema.Struct({ ok: Schema.Literal(true) }),
|
|
604
|
+
InputPreparationError,
|
|
605
|
+
]);
|