@fastbrowse/linux-x64 0.7.1 → 0.7.4
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/fastbrowse/fastbrowse +0 -0
- package/fastbrowse/fastbrowse-mcp +0 -0
- package/fastbrowse.node +0 -0
- package/package.json +3 -2
- package/fastbrowse/_internal/base_library.zip +0 -0
- package/fastbrowse/_internal/certifi/cacert.pem +0 -3959
- package/fastbrowse/_internal/certifi/py.typed +0 -0
- package/fastbrowse/_internal/fastbrowse/browser/autoconsent/LICENSE +0 -373
- package/fastbrowse/_internal/fastbrowse/browser/autoconsent/SOURCE.txt +0 -20
- package/fastbrowse/_internal/fastbrowse/browser/autoconsent/autoconsent.standalone.js +0 -3543
- package/fastbrowse/_internal/fastbrowse/browser/capture.js +0 -499
- package/fastbrowse/_internal/fastbrowse/browser/snapshot.js +0 -1098
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/INSTALLER +0 -1
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/METADATA +0 -616
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/RECORD +0 -14
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/REQUESTED +0 -0
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/WHEEL +0 -4
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/direct_url.json +0 -1
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/entry_points.txt +0 -3
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/licenses/LICENSE +0 -21
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/licenses/NOTICE +0 -33
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/uv_build.json +0 -1
- package/fastbrowse/_internal/fastbrowse-0.7.1.dist-info/uv_cache.json +0 -1
- package/fastbrowse/_internal/libgcc_s.so.1 +0 -0
- package/fastbrowse/_internal/libpython3.13.so.1.0 +0 -0
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/INSTALLER +0 -1
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/METADATA +0 -1294
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/RECORD +0 -113
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/REQUESTED +0 -0
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/WHEEL +0 -4
- package/fastbrowse/_internal/pydantic-2.13.4.dist-info/licenses/LICENSE +0 -21
- package/fastbrowse/_internal/pydantic_core/_pydantic_core.cpython-313-x86_64-linux-gnu.so +0 -0
- package/fastbrowse/_internal/setuptools/_vendor/jaraco/text/Lorem ipsum.txt +0 -2
- package/fastbrowse/_internal/websockets/speedups.cpython-313-x86_64-linux-gnu.so +0 -0
- package/fastbrowse/_internal/websockets-17.2.dist-info/INSTALLER +0 -1
- package/fastbrowse/_internal/websockets-17.2.dist-info/METADATA +0 -170
- package/fastbrowse/_internal/websockets-17.2.dist-info/RECORD +0 -65
- package/fastbrowse/_internal/websockets-17.2.dist-info/REQUESTED +0 -0
- package/fastbrowse/_internal/websockets-17.2.dist-info/WHEEL +0 -7
- package/fastbrowse/_internal/websockets-17.2.dist-info/entry_points.txt +0 -2
- package/fastbrowse/_internal/websockets-17.2.dist-info/licenses/LICENSE +0 -24
- package/fastbrowse/_internal/websockets-17.2.dist-info/top_level.txt +0 -1
|
@@ -1,1098 +0,0 @@
|
|
|
1
|
-
// Ported from jev-ultrafast (MIT): jev_ultrafast/snapshot.js.
|
|
2
|
-
// Atomic single-evaluate observation of one frame's controls, viewport text, and a freshness fingerprint.
|
|
3
|
-
// Adapted to fastbrowse's Control shape (role + operation set, not per-kind actions) and to traverse
|
|
4
|
-
// open shadow roots. Runs once per frame session (main frame or an OOPIF); the Python side merges frames.
|
|
5
|
-
// biome-ignore format: Python appends mode arguments to this parenthesized expression.
|
|
6
|
-
((mode, allowedOrigins = null, limits = null) => {
|
|
7
|
-
const granted = doc => {
|
|
8
|
-
if (allowedOrigins === null) return true;
|
|
9
|
-
const frame = doc.defaultView.frameElement;
|
|
10
|
-
if (frame) {
|
|
11
|
-
if (frame.hasAttribute('srcdoc') || !frame.getAttribute('src')) return false;
|
|
12
|
-
const source = new URL(frame.src);
|
|
13
|
-
if (!['http:', 'https:'].includes(source.protocol) || !allowedOrigins.includes(source.origin)) return false;
|
|
14
|
-
}
|
|
15
|
-
const url = new URL(doc.URL);
|
|
16
|
-
return ['http:', 'https:'].includes(url.protocol) && allowedOrigins.includes(url.origin);
|
|
17
|
-
};
|
|
18
|
-
// Form context is repeated for each submit-capable input in safety prompts.
|
|
19
|
-
const FORM_TEXT_CHARS = 2000;
|
|
20
|
-
// Freshness guards bound comparison work on large cards; this text is never sent to a model.
|
|
21
|
-
const GUARD_TEXT_CHARS = 6000;
|
|
22
|
-
// Twin controls repeat their card heading, so keep its excerpt shorter than the control itself.
|
|
23
|
-
const CONTROL_CONTEXT_CHARS = 120;
|
|
24
|
-
// A hover target can be a whole card whose text would crowd out the other controls.
|
|
25
|
-
const HOVER_LABEL_CHARS = 200;
|
|
26
|
-
// Each hover rule fans out to DOM queries, so bound stylesheet work per observation.
|
|
27
|
-
const MAX_HOVER_RULES = 200;
|
|
28
|
-
// Hover targets supplement the actionable controls rather than fill their observation budget.
|
|
29
|
-
const MAX_HOVER_TARGETS = 40;
|
|
30
|
-
// Each candidate checks hidden descendants, which can stall a dense page's observation.
|
|
31
|
-
const MAX_HOVER_CHECKS = 400;
|
|
32
|
-
const excerpt = (text, cap) =>
|
|
33
|
-
text.length <= cap ? text : `${text.slice(0, cap)} [${text.length - cap} characters omitted]`;
|
|
34
|
-
// A transparent native checkbox is the one control TodoMVC shows, so only its own opacity is excused: a
|
|
35
|
-
// transparent ancestor still hides it. Walked by style, not layout box, as a `display: contents` parent
|
|
36
|
-
// generates no box yet hides nothing.
|
|
37
|
-
const nativeChoice = e => e.tagName === 'INPUT' && ['checkbox', 'radio'].includes(e.type);
|
|
38
|
-
const shownThrough = e => {
|
|
39
|
-
for (let a = e.parentElement ?? e.getRootNode().host; a; a = a.parentElement ?? a.getRootNode().host)
|
|
40
|
-
if (a.ownerDocument.defaultView.getComputedStyle(a).opacity === '0') return false;
|
|
41
|
-
return true;
|
|
42
|
-
};
|
|
43
|
-
const registry = (window.__fastbrowse ||= { ids: new WeakMap(), nodes: new Map(), next: 1 });
|
|
44
|
-
// Keep the page's own timer overrides from holding a browser-side frame wait open.
|
|
45
|
-
registry.nativeSetTimeout ||= window.setTimeout.bind(window);
|
|
46
|
-
registry.nativeClearTimeout ||= window.clearTimeout.bind(window);
|
|
47
|
-
registry.nativeRequestAnimationFrame ||= window.requestAnimationFrame.bind(window);
|
|
48
|
-
// What was offered to be acted on; `ids` also names hover targets and fingerprinted nodes.
|
|
49
|
-
registry.controls ||= new WeakSet();
|
|
50
|
-
// A visible loading indicator means the page is still fetching what it will draw, which network idle and a
|
|
51
|
-
// quiet DOM both report as settled: a spinner mutates nothing while it spins. Named classes are a heuristic
|
|
52
|
-
// and deliberately so -- the wait on them is bounded and expires into proceeding, so a false match costs
|
|
53
|
-
// time and never correctness. Hidden indicators are ignored because most pages keep one in the DOM always.
|
|
54
|
-
const LOADING =
|
|
55
|
-
'[aria-busy="true"],[role="progressbar"],progress:not([value]),' +
|
|
56
|
-
['spinner', 'loading', 'loader', 'skeleton', 'shimmer']
|
|
57
|
-
.flatMap(name => [`[class*="${name}" i]`, `[id*="${name}" i]`])
|
|
58
|
-
.join(',');
|
|
59
|
-
// Only an indicator on screen is waited on. GitHub keeps an empty fixed progress bar and lazy skeletons below
|
|
60
|
-
// the fold, which load only once scrolled to: waiting on them spent the full wait on every GitHub page.
|
|
61
|
-
const showing = e => {
|
|
62
|
-
if (!e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
|
|
63
|
-
const r = e.getBoundingClientRect();
|
|
64
|
-
const view = e.ownerDocument.defaultView || window;
|
|
65
|
-
return (
|
|
66
|
-
r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < view.innerHeight && r.left < view.innerWidth
|
|
67
|
-
);
|
|
68
|
-
};
|
|
69
|
-
if (!registry.track) {
|
|
70
|
-
const roots = new WeakSet();
|
|
71
|
-
const changed = () => {
|
|
72
|
-
registry.lastMutation = performance.now();
|
|
73
|
-
};
|
|
74
|
-
const observer = new MutationObserver(changed);
|
|
75
|
-
registry.track = root => {
|
|
76
|
-
if (roots.has(root)) return;
|
|
77
|
-
roots.add(root);
|
|
78
|
-
observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true });
|
|
79
|
-
// Input handlers can defer a navigation or redraw without mutating yet. Start the quiet clock at the
|
|
80
|
-
// event, so the reply's travel time counts toward settling even when the action changes nothing.
|
|
81
|
-
for (const event of [
|
|
82
|
-
'input',
|
|
83
|
-
'change',
|
|
84
|
-
'scroll',
|
|
85
|
-
'wheel',
|
|
86
|
-
'pointermove',
|
|
87
|
-
'pointerdown',
|
|
88
|
-
'pointerup',
|
|
89
|
-
'keydown',
|
|
90
|
-
'keyup',
|
|
91
|
-
])
|
|
92
|
-
root.addEventListener(event, changed, true);
|
|
93
|
-
changed();
|
|
94
|
-
};
|
|
95
|
-
registry.track(document);
|
|
96
|
-
}
|
|
97
|
-
const clock = () => ({
|
|
98
|
-
ready: document.readyState === 'interactive' || document.readyState === 'complete',
|
|
99
|
-
quietFor: performance.now() - registry.lastMutation,
|
|
100
|
-
hidden: document.hidden,
|
|
101
|
-
});
|
|
102
|
-
// The settle loop reads the clock alone until the page has gone quiet: the walk below reads every element and
|
|
103
|
-
// all the page's text, which on a large page costs more than the poll interval leaves room for.
|
|
104
|
-
if (mode === 'quiet') return clock();
|
|
105
|
-
// `loading` walks for indicators, and to watch new shadow roots and frames, but hashes nothing.
|
|
106
|
-
if (mode === 'fingerprint' || mode === 'loading') {
|
|
107
|
-
const hashing = mode === 'fingerprint';
|
|
108
|
-
let hash = 2166136261;
|
|
109
|
-
let loading = false;
|
|
110
|
-
const hashText = text => {
|
|
111
|
-
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
112
|
-
};
|
|
113
|
-
const include = root => {
|
|
114
|
-
registry.track(root);
|
|
115
|
-
loading ||= [...root.querySelectorAll(LOADING)].some(showing);
|
|
116
|
-
if (hashing) hashText(root.body?.innerText ?? root.textContent ?? '');
|
|
117
|
-
// innerText omits shadow trees and child documents even when their content is visible.
|
|
118
|
-
for (const e of root.querySelectorAll('*')) {
|
|
119
|
-
// A filter can change only its checkmark, so text alone reported "Nonstop only" as a no-op.
|
|
120
|
-
// Field values stay out: the run judges fills by the value it wrote, even when a popup changes.
|
|
121
|
-
// Only rendered controls: a hidden carousel's aria-selected dots churn on their own and would read as
|
|
122
|
-
// progress.
|
|
123
|
-
if (
|
|
124
|
-
hashing &&
|
|
125
|
-
('checked' in e || 'selected' in e || e.hasAttribute('aria-checked') || e.hasAttribute('aria-selected')) &&
|
|
126
|
-
e.checkVisibility({ checkOpacity: !nativeChoice(e), checkVisibilityCSS: true }) &&
|
|
127
|
-
(!nativeChoice(e) || shownThrough(e))
|
|
128
|
-
)
|
|
129
|
-
hashText(
|
|
130
|
-
JSON.stringify([
|
|
131
|
-
e.checked ?? null,
|
|
132
|
-
e.selected ?? null,
|
|
133
|
-
e.getAttribute('aria-checked'),
|
|
134
|
-
e.getAttribute('aria-selected'),
|
|
135
|
-
]),
|
|
136
|
-
);
|
|
137
|
-
if (e.shadowRoot) include(e.shadowRoot);
|
|
138
|
-
if (e.tagName === 'IFRAME' || e.tagName === 'FRAME') {
|
|
139
|
-
let inner = null;
|
|
140
|
-
try {
|
|
141
|
-
inner = e.contentDocument;
|
|
142
|
-
} catch {
|
|
143
|
-
inner = null;
|
|
144
|
-
}
|
|
145
|
-
if (inner?.body && granted(inner)) include(inner);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
include(document);
|
|
150
|
-
return {
|
|
151
|
-
...clock(),
|
|
152
|
-
fingerprint: hashing ? location.href + '|' + document.title + '|' + (hash >>> 0) + '|' + scrollY : null,
|
|
153
|
-
loading,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
if (!document.body) return null;
|
|
157
|
-
const identity = e => {
|
|
158
|
-
if (!registry.ids.has(e)) registry.ids.set(e, registry.next++);
|
|
159
|
-
const id = registry.ids.get(e);
|
|
160
|
-
registry.nodes.set(id, e);
|
|
161
|
-
return id;
|
|
162
|
-
};
|
|
163
|
-
for (const [id, e] of registry.nodes) if (!e.isConnected) registry.nodes.delete(id);
|
|
164
|
-
// The DOM cannot change while this script runs, so a label, a text or a section title read once is reused for
|
|
165
|
-
// the rest of it. `registry.guard` reads them again in a later script, against a page that may have changed
|
|
166
|
-
// since, so the reuse ends as soon as this one does, whether it returns or throws.
|
|
167
|
-
const caches = [];
|
|
168
|
-
registry.nativeQueueMicrotask ||= window.queueMicrotask.bind(window);
|
|
169
|
-
registry.nativeQueueMicrotask(() => {
|
|
170
|
-
for (const cache of caches) cache.clear();
|
|
171
|
-
caches.length = 0;
|
|
172
|
-
});
|
|
173
|
-
const memo = read => {
|
|
174
|
-
const cache = new Map();
|
|
175
|
-
caches.push(cache);
|
|
176
|
-
return e => {
|
|
177
|
-
if (!caches.includes(cache)) return read(e);
|
|
178
|
-
if (!cache.has(e)) cache.set(e, read(e));
|
|
179
|
-
return cache.get(e);
|
|
180
|
-
};
|
|
181
|
-
};
|
|
182
|
-
const innerTextOf = memo(e => e.innerText);
|
|
183
|
-
|
|
184
|
-
const safe = e => e.type !== 'hidden';
|
|
185
|
-
// Password and agent-typed secret values never leave the page: only their length is observed.
|
|
186
|
-
const secret = e => e.type === 'password' || e.dataset?.fastbrowseSecret === '1';
|
|
187
|
-
const reveal = e => (typeof e.value !== 'string' ? null : secret(e) ? '•'.repeat(e.value.length) : e.value);
|
|
188
|
-
// Asked of each control by the walk and again by its guard, and of each text node's parent.
|
|
189
|
-
const visible = memo(e => {
|
|
190
|
-
// Todo lists paint the checkmark beside a transparent native input without associating a label.
|
|
191
|
-
// The input still receives clicks; opacity alone must not remove the only control for that row.
|
|
192
|
-
// A transparent input with a visible label of its own keeps the label as its click target: TodoMVC's
|
|
193
|
-
// "Mark all as complete" input is 1px and off screen, and only its label can be clicked.
|
|
194
|
-
const choice =
|
|
195
|
-
nativeChoice(e) &&
|
|
196
|
-
e.ownerDocument.defaultView.getComputedStyle(e).pointerEvents !== 'none' &&
|
|
197
|
-
![...(e.labels || [])].some(l => l.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }));
|
|
198
|
-
if (
|
|
199
|
-
e.closest('[aria-hidden="true"],[inert]') ||
|
|
200
|
-
!e.checkVisibility({ checkOpacity: !choice, checkVisibilityCSS: true }) ||
|
|
201
|
-
(choice && !shownThrough(e))
|
|
202
|
-
)
|
|
203
|
-
return false;
|
|
204
|
-
if (!choice) return true;
|
|
205
|
-
const r = e.getBoundingClientRect();
|
|
206
|
-
return r.width > 0 && r.height > 0 && !e.matches(':disabled') && !e.closest('[aria-disabled="true"]');
|
|
207
|
-
});
|
|
208
|
-
registry.visible = visible;
|
|
209
|
-
// Styled checkboxes and radios often hide the native input. Its visible label is the click
|
|
210
|
-
// target, but the input still owns the checked/disabled state and must participate in freshness.
|
|
211
|
-
const sourceOf = e => (e.tagName === 'LABEL' && ['checkbox', 'radio', 'file'].includes(e.control?.type) ? e.control : e);
|
|
212
|
-
|
|
213
|
-
// Their text is code, not a name: Amazon nests a <style> inside a result card's link.
|
|
214
|
-
const CODE = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
215
|
-
// Icon-only buttons hide their glyph from accessibility text, but the icon library still names it: in a class,
|
|
216
|
-
// a sprite reference, a data attribute, an svg title or a ligature. A gallery's trash control otherwise reads
|
|
217
|
-
// "button" beside an edit link that reads "link", and the choice cannot tell removing an image from editing it.
|
|
218
|
-
const ICON_CLASS =
|
|
219
|
-
/^(?:icon-tabler|tabler-icon|lucide|glyphicon|fa|fas|far|bi|mdi|icon|ti|la|ri|ion|feather)-([a-z][a-z0-9-]{0,47})$/;
|
|
220
|
-
// Sizes, weights and effects share an icon library's prefix without naming a glyph, as does the library itself.
|
|
221
|
-
const ICON_STYLE =
|
|
222
|
-
/^(?:icon|tabler|solid|regular|light|thin|duotone|brands|xs|sm|lg|xl|\d+x|fw|spin|pulse|border|inverse|stack.*|pull-.*|rotate-.*|flip-.*)$/;
|
|
223
|
-
const glyphName = text => {
|
|
224
|
-
const name = (text || '').trim().toLowerCase().replace(/^(?:icons?|ico|ic|svg|sprite)[-_]/, '');
|
|
225
|
-
return /^[a-z][a-z0-9_-]{0,47}$/.test(name) && !ICON_STYLE.test(name) ? name : '';
|
|
226
|
-
};
|
|
227
|
-
const iconLabel = e => {
|
|
228
|
-
const glyphs = new Set();
|
|
229
|
-
for (const icon of e.querySelectorAll('svg,i,span,[role="img"]')) {
|
|
230
|
-
if (!icon.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
|
|
231
|
-
const named = [];
|
|
232
|
-
for (const token of icon.classList) {
|
|
233
|
-
const glyph = ICON_CLASS.exec(token);
|
|
234
|
-
if (glyph && !ICON_STYLE.test(glyph[1])) named.push(glyph[1]);
|
|
235
|
-
}
|
|
236
|
-
const use = icon.localName === 'svg' && icon.querySelector('use');
|
|
237
|
-
const sprite = use && (use.getAttribute('href') || use.getAttribute('xlink:href') || '');
|
|
238
|
-
if (sprite) named.push(glyphName(sprite.split('#').pop().split('/').pop().replace(/\.svg$/, '')));
|
|
239
|
-
named.push(glyphName(icon.getAttribute('data-icon')));
|
|
240
|
-
// A ligature font draws the glyph its text names, and the icon is hidden so readers skip that word.
|
|
241
|
-
if ([...icon.classList].some(token => /^material-(?:icons|symbols)/.test(token))) {
|
|
242
|
-
named.push(glyphName(icon.textContent.replace(/\s+/g, '_')));
|
|
243
|
-
}
|
|
244
|
-
const title =
|
|
245
|
-
icon.getAttribute('aria-label') || (icon.localName === 'svg' ? icon.querySelector(':scope > title')?.textContent : '');
|
|
246
|
-
if (title?.trim()) glyphs.add(title.replace(/\s+/g, ' ').trim());
|
|
247
|
-
else for (const name of named) if (name) glyphs.add(`${name.replace(/[-_]/g, ' ')} icon`);
|
|
248
|
-
}
|
|
249
|
-
return glyphs.size === 1 ? [...glyphs][0] : '';
|
|
250
|
-
};
|
|
251
|
-
// An accessible description is what an unnamed control has left that says what it does.
|
|
252
|
-
const describedBy = e => {
|
|
253
|
-
const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
|
|
254
|
-
return (e.getAttribute('aria-describedby') || '')
|
|
255
|
-
.split(/\s+/)
|
|
256
|
-
.map(id => (id && (root.getElementById ? root.getElementById(id) : document.getElementById(id))?.textContent) || '')
|
|
257
|
-
.map(text => text.replace(/\s+/g, ' ').trim())
|
|
258
|
-
.filter(Boolean)
|
|
259
|
-
.join(' ');
|
|
260
|
-
};
|
|
261
|
-
const labelFrom = (e, seen) => {
|
|
262
|
-
if (!e || seen.has(e)) return '';
|
|
263
|
-
seen.add(e);
|
|
264
|
-
const root = typeof e.getRootNode === 'function' ? e.getRootNode() : document;
|
|
265
|
-
const byId = id => (root.getElementById ? root.getElementById(id) : document.getElementById(id));
|
|
266
|
-
const referenced = (e.getAttribute('aria-labelledby') || '')
|
|
267
|
-
.split(/\s+/)
|
|
268
|
-
.map(id => labelFrom(byId(id), seen))
|
|
269
|
-
.filter(Boolean)
|
|
270
|
-
.join(' ');
|
|
271
|
-
return (
|
|
272
|
-
referenced ||
|
|
273
|
-
e.getAttribute('aria-label') ||
|
|
274
|
-
[...(e.labels || [])]
|
|
275
|
-
.map(l => labelFrom(l, seen))
|
|
276
|
-
.filter(Boolean)
|
|
277
|
-
.join(' ') ||
|
|
278
|
-
(['button', 'submit', 'reset'].includes(e.type) ? e.value : '') ||
|
|
279
|
-
e.getAttribute('alt') ||
|
|
280
|
-
(e.tagName === 'INPUT'
|
|
281
|
-
? ''
|
|
282
|
-
: [...e.childNodes]
|
|
283
|
-
.map(n =>
|
|
284
|
-
n.nodeType === 3
|
|
285
|
-
? n.textContent
|
|
286
|
-
: n.nodeType === 1 && !CODE.has(n.tagName) && n.getAttribute('aria-hidden') !== 'true'
|
|
287
|
-
? labelFrom(n, seen)
|
|
288
|
-
: '',
|
|
289
|
-
)
|
|
290
|
-
.join(' ')
|
|
291
|
-
.trim()) ||
|
|
292
|
-
e.getAttribute('title') ||
|
|
293
|
-
e.getAttribute('placeholder') ||
|
|
294
|
-
''
|
|
295
|
-
);
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
const labelOf = memo(e => labelFrom(e, new Set()));
|
|
299
|
-
|
|
300
|
-
const ARIA_ROLES = [
|
|
301
|
-
'button',
|
|
302
|
-
'link',
|
|
303
|
-
'checkbox',
|
|
304
|
-
'radio',
|
|
305
|
-
'switch',
|
|
306
|
-
'tab',
|
|
307
|
-
'menuitem',
|
|
308
|
-
'menuitemradio',
|
|
309
|
-
'option',
|
|
310
|
-
'gridcell',
|
|
311
|
-
'combobox',
|
|
312
|
-
'textbox',
|
|
313
|
-
'searchbox',
|
|
314
|
-
'spinbutton',
|
|
315
|
-
];
|
|
316
|
-
// jQuery UI's datepicker prev/next are anchors with no href, appended to <body> once the field is focused:
|
|
317
|
-
// they carry no destination, only a click handler, so they need naming here to be walked at all.
|
|
318
|
-
const SELECTOR =
|
|
319
|
-
'a[href],a[data-handler][data-event="click"],a[onclick],a[role="button"],' +
|
|
320
|
-
'button,input,textarea,select,summary,label,[contenteditable="true"],[draggable="true"],[ondrop],' +
|
|
321
|
-
ARIA_ROLES.map(role => `[role="${role}"]`).join(',');
|
|
322
|
-
// Types whose value is a calendar date, not free text: the field writer must produce the type's ISO shape
|
|
323
|
-
// and the native value setter, not typed keystrokes, is what a picker widget actually commits.
|
|
324
|
-
const DATE_TYPES = ['date', 'datetime-local', 'month', 'week', 'time'];
|
|
325
|
-
|
|
326
|
-
const roleOf = e => {
|
|
327
|
-
if (sourceOf(e) !== e) return sourceOf(e).type === 'file' ? 'textbox' : sourceOf(e).type;
|
|
328
|
-
const explicit = e.getAttribute('role');
|
|
329
|
-
if (ARIA_ROLES.includes(explicit)) return explicit;
|
|
330
|
-
if (e.tagName === 'BUTTON' || e.tagName === 'SUMMARY') return 'button';
|
|
331
|
-
// An anchor with nowhere to go acts through its click handler alone, like the jQuery UI datepicker's
|
|
332
|
-
// Next/Prev: a destination is what makes it a link, so without one it is offered as a button instead.
|
|
333
|
-
if (e.tagName === 'A') return e.hasAttribute('href') ? 'link' : 'button';
|
|
334
|
-
if (e.tagName === 'SELECT') return 'combobox';
|
|
335
|
-
if (e.tagName === 'TEXTAREA' || e.isContentEditable) return 'textbox';
|
|
336
|
-
if (e.tagName === 'INPUT') {
|
|
337
|
-
if (e.type === 'file') return 'textbox';
|
|
338
|
-
if (['checkbox', 'radio'].includes(e.type)) return e.type;
|
|
339
|
-
if (['button', 'submit', 'reset', 'image'].includes(e.type)) return 'button';
|
|
340
|
-
if (e.type === 'search') return 'searchbox';
|
|
341
|
-
if (e.type === 'number') return 'spinbutton';
|
|
342
|
-
if (['text', 'email', 'url', 'tel', 'password', ...DATE_TYPES].includes(e.type)) return 'textbox';
|
|
343
|
-
}
|
|
344
|
-
// A bare draggable element or a declared drop target has no ARIA role of its own, yet it is a real
|
|
345
|
-
// element to act on. Name it generic rather than null so the control walk does not drop it before the
|
|
346
|
-
// drag it offers can be seen.
|
|
347
|
-
if (e.getAttribute('draggable') === 'true' || e.hasAttribute('ondrop')) return 'generic';
|
|
348
|
-
return null;
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
const submitSemantics = memo(e => {
|
|
352
|
-
const form = e.form;
|
|
353
|
-
const explicit = ['BUTTON', 'INPUT'].includes(e.tagName) && ['submit', 'image'].includes(e.type);
|
|
354
|
-
const implicit = [
|
|
355
|
-
'text',
|
|
356
|
-
'search',
|
|
357
|
-
'url',
|
|
358
|
-
'tel',
|
|
359
|
-
'email',
|
|
360
|
-
'password',
|
|
361
|
-
'date',
|
|
362
|
-
'month',
|
|
363
|
-
'week',
|
|
364
|
-
'time',
|
|
365
|
-
'datetime-local',
|
|
366
|
-
'number',
|
|
367
|
-
];
|
|
368
|
-
if (!form || (!explicit && (e.tagName !== 'INPUT' || !implicit.includes(e.type)))) return null;
|
|
369
|
-
const submit = explicit ? e : [...form.getRootNode().querySelectorAll('button,input')].find(
|
|
370
|
-
c => c.form === form && (c.type === 'submit' || c.type === 'image'),
|
|
371
|
-
);
|
|
372
|
-
if (submit?.matches(':disabled')) return null;
|
|
373
|
-
if (!submit && [...form.elements].filter(c => c.tagName === 'INPUT' && implicit.includes(c.type)).length > 1)
|
|
374
|
-
return null;
|
|
375
|
-
return JSON.stringify({
|
|
376
|
-
label: submit ? labelOf(submit) : labelOf(form),
|
|
377
|
-
method: submit?.getAttribute('formmethod') || form.method,
|
|
378
|
-
action: submit?.getAttribute('formaction') || form.action,
|
|
379
|
-
text: excerpt(innerTextOf(form), FORM_TEXT_CHARS),
|
|
380
|
-
});
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
const framePath = doc => {
|
|
384
|
-
if (doc === document) return null;
|
|
385
|
-
const owner = doc.defaultView.frameElement;
|
|
386
|
-
return `${framePath(owner.ownerDocument) || 'root'}/${identity(owner)}`;
|
|
387
|
-
};
|
|
388
|
-
registry.framePath = framePath;
|
|
389
|
-
registry.submitSemantics = submitSemantics;
|
|
390
|
-
|
|
391
|
-
// Traverse light DOM plus any open shadow roots, and same-origin same-process nested iframes.
|
|
392
|
-
let inaccessible = 0;
|
|
393
|
-
function* walk(root) {
|
|
394
|
-
// A document observer cannot see mutations inside shadow roots or child documents.
|
|
395
|
-
registry.track(root);
|
|
396
|
-
for (const e of root.querySelectorAll(SELECTOR)) yield e;
|
|
397
|
-
for (const e of root.querySelectorAll('*')) {
|
|
398
|
-
if (e.shadowRoot) yield* walk(e.shadowRoot);
|
|
399
|
-
if (e.tagName === 'IFRAME' || e.tagName === 'FRAME') {
|
|
400
|
-
let inner = null;
|
|
401
|
-
try {
|
|
402
|
-
inner = e.contentDocument;
|
|
403
|
-
} catch {
|
|
404
|
-
inner = null;
|
|
405
|
-
}
|
|
406
|
-
if (inner && inner.body && granted(inner)) yield* walk(inner);
|
|
407
|
-
else inaccessible++;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Guards and the page key go to Python as digests: each comparison is equality, and the full guards cost more to
|
|
413
|
-
// send than the snapshot takes to compute. Not cryptographic, and it needs not be: a page that wants a guard to
|
|
414
|
-
// hold can simply leave its DOM alone.
|
|
415
|
-
const digest = text => {
|
|
416
|
-
let h1 = 0xdeadbeef,
|
|
417
|
-
h2 = 0x41c6ce57;
|
|
418
|
-
for (let i = 0; i < text.length; i++) {
|
|
419
|
-
const c = text.charCodeAt(i);
|
|
420
|
-
h1 = Math.imul(h1 ^ c, 2654435761);
|
|
421
|
-
h2 = Math.imul(h2 ^ c, 1597334677);
|
|
422
|
-
}
|
|
423
|
-
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
424
|
-
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
425
|
-
return (h2 >>> 0).toString(16).padStart(8, '0') + (h1 >>> 0).toString(16).padStart(8, '0');
|
|
426
|
-
};
|
|
427
|
-
// Sibling controls share their card, so its text is hashed once per card rather than once per control.
|
|
428
|
-
const scopeDigest = memo(scope => digest(innerTextOf(scope)?.slice(0, GUARD_TEXT_CHARS) || ''));
|
|
429
|
-
registry.guard = e => {
|
|
430
|
-
if (!e?.isConnected || !visible(e)) return null;
|
|
431
|
-
const source = sourceOf(e);
|
|
432
|
-
if (source !== e && (source.matches(':disabled') || source.closest('[aria-disabled="true"],[inert]'))) return null;
|
|
433
|
-
const scope = e.closest('form,dialog,[role="dialog"],article,li,tr,[role="row"]') || e.parentElement;
|
|
434
|
-
// An absent aria-disabled and "false" both mean enabled. Google Flights adds the "false" as it hydrates, and
|
|
435
|
-
// the raw attribute turned the run's first click stale.
|
|
436
|
-
return [
|
|
437
|
-
identity(e),
|
|
438
|
-
roleOf(e),
|
|
439
|
-
labelOf(e),
|
|
440
|
-
reveal(source),
|
|
441
|
-
source.checked ?? null,
|
|
442
|
-
e.selectedIndex ?? null,
|
|
443
|
-
e.readOnly ?? null,
|
|
444
|
-
e.matches(':disabled'),
|
|
445
|
-
e.getAttribute('aria-disabled') === 'true',
|
|
446
|
-
e.getAttribute('aria-expanded'),
|
|
447
|
-
e.getAttribute('aria-checked'),
|
|
448
|
-
e.getAttribute('aria-selected'),
|
|
449
|
-
e.getAttribute('href'),
|
|
450
|
-
scope ? scopeDigest(scope) : '',
|
|
451
|
-
e.ownerDocument.location.origin,
|
|
452
|
-
e.ownerDocument.defaultView.performance.timeOrigin,
|
|
453
|
-
submitSemantics(e),
|
|
454
|
-
source.getAttribute('name'),
|
|
455
|
-
source.getAttribute('autocomplete'),
|
|
456
|
-
identity(source),
|
|
457
|
-
];
|
|
458
|
-
};
|
|
459
|
-
registry.guardKey = e => {
|
|
460
|
-
const guard = registry.guard(e);
|
|
461
|
-
return guard && digest(JSON.stringify(guard));
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
const controls = [];
|
|
465
|
-
// Controls with no accessible name of their own, whose label is an icon's or only their role.
|
|
466
|
-
const unnamed = new Set();
|
|
467
|
-
const fieldScope = e => {
|
|
468
|
-
if (!e.matches('input,textarea,select')) return null;
|
|
469
|
-
const form = e.form || e.closest('form,fieldset,[role="form"]');
|
|
470
|
-
if (form) return String(identity(form));
|
|
471
|
-
// Script-built forms can use a local group of labelled fields without an HTML form owner.
|
|
472
|
-
// Stop at document sections so a sidebar search never joins the content's fields.
|
|
473
|
-
for (
|
|
474
|
-
let p = e.parentElement;
|
|
475
|
-
p && !p.matches('body,main,article,section,aside,nav,header,footer');
|
|
476
|
-
p = p.parentElement
|
|
477
|
-
) {
|
|
478
|
-
if (p.querySelectorAll('input:not([type=hidden]),textarea,select').length > 1) return String(identity(p));
|
|
479
|
-
}
|
|
480
|
-
return null;
|
|
481
|
-
};
|
|
482
|
-
for (const e of walk(document)) {
|
|
483
|
-
if (!safe(e) || !visible(e) || e.matches(':disabled') || e.closest('[aria-disabled="true"],[inert]')) continue;
|
|
484
|
-
const source = sourceOf(e);
|
|
485
|
-
if (
|
|
486
|
-
source !== e &&
|
|
487
|
-
(visible(source) || source.matches(':disabled') || source.closest('[aria-disabled="true"],[inert]'))
|
|
488
|
-
)
|
|
489
|
-
continue;
|
|
490
|
-
const r = e.getBoundingClientRect(),
|
|
491
|
-
x = r.x + r.width / 2,
|
|
492
|
-
y = r.y + r.height / 2,
|
|
493
|
-
rname = roleOf(e);
|
|
494
|
-
if (!rname || r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth) continue;
|
|
495
|
-
if (rname === 'gridcell' && e.querySelector('button,[role="button"]')) continue;
|
|
496
|
-
// A button-styled link offers the same label twice, but its inner button owns every useful click point.
|
|
497
|
-
if (rname === 'link') {
|
|
498
|
-
// Most links hold no button, and querying every link for every control was the walk's slowest step.
|
|
499
|
-
const nested = e.querySelector('button') ? e.querySelectorAll(SELECTOR) : [];
|
|
500
|
-
const button = nested.length === 1 ? nested[0] : null;
|
|
501
|
-
if (button?.tagName === 'BUTTON' && visible(button) && !button.matches(':disabled') &&
|
|
502
|
-
!button.closest('[aria-disabled="true"],[inert]') && labelOf(button) === labelOf(e)) continue;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const id = identity(e);
|
|
506
|
-
registry.controls.add(e);
|
|
507
|
-
const named = labelOf(e);
|
|
508
|
-
if (!named) unnamed.add(id);
|
|
509
|
-
const base = {
|
|
510
|
-
id,
|
|
511
|
-
role: rname,
|
|
512
|
-
label: named || iconLabel(e) || describedBy(e) || rname,
|
|
513
|
-
offscreen: y < 0 || y >= innerHeight,
|
|
514
|
-
distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
|
|
515
|
-
sensitive: secret(source),
|
|
516
|
-
input_type: source.type || null,
|
|
517
|
-
input_name: source.getAttribute('name') || null,
|
|
518
|
-
autocomplete: source.getAttribute('autocomplete') || null,
|
|
519
|
-
frame_origin: e.ownerDocument.location.origin,
|
|
520
|
-
frame_path: framePath(e.ownerDocument),
|
|
521
|
-
form_id: fieldScope(source),
|
|
522
|
-
submit_semantics: submitSemantics(e),
|
|
523
|
-
};
|
|
524
|
-
if (rname === 'link' && e.href) {
|
|
525
|
-
const u = new URL(e.href, location.href);
|
|
526
|
-
base.href = u.origin === location.origin ? u.pathname + u.search : u.host + u.pathname;
|
|
527
|
-
// The page's own word for "the next page of this list", which survives an icon label or another language.
|
|
528
|
-
if (e.rel && e.rel.split(/\s+/).includes('next')) base.next_page = true;
|
|
529
|
-
// The page's own word that this link saves a file, which a task ruling out downloads has to see before a click.
|
|
530
|
-
if (e.hasAttribute('download')) base.download = true;
|
|
531
|
-
}
|
|
532
|
-
for (const key of ['checked', 'selected', 'expanded']) {
|
|
533
|
-
const value = e.getAttribute('aria-' + key);
|
|
534
|
-
if (value !== null) base[key] = value === 'true';
|
|
535
|
-
}
|
|
536
|
-
if (['checkbox', 'radio'].includes(source.type)) base.checked = source.checked;
|
|
537
|
-
// A field a form will not submit without: required and still empty, or marked invalid by the page.
|
|
538
|
-
// A custom widget marks the element the user sees, not the input underneath it, so both are asked.
|
|
539
|
-
if (
|
|
540
|
-
e.getAttribute('aria-invalid') === 'true' ||
|
|
541
|
-
source.getAttribute('aria-invalid') === 'true' ||
|
|
542
|
-
(['INPUT', 'SELECT', 'TEXTAREA'].includes(source.tagName) &&
|
|
543
|
-
(source.required || source.getAttribute('aria-required') === 'true') &&
|
|
544
|
-
(source.type === 'radio' && source.required
|
|
545
|
-
? source.validity.valueMissing
|
|
546
|
-
: !(base.checked ?? (source.value ?? '').trim())))
|
|
547
|
-
) {
|
|
548
|
-
base.blocking = true;
|
|
549
|
-
}
|
|
550
|
-
if (e.tagName === 'SELECT') {
|
|
551
|
-
base.operations = ['select'];
|
|
552
|
-
base.options = [...e.options].filter(o => !o.disabled && !o.closest('optgroup[disabled]')).map(o => o.label);
|
|
553
|
-
base.value = [...e.selectedOptions].map(o => o.label).join(', ');
|
|
554
|
-
} else if (source.type === 'file') {
|
|
555
|
-
base.operations = ['upload'];
|
|
556
|
-
base.value = null;
|
|
557
|
-
} else {
|
|
558
|
-
const editable =
|
|
559
|
-
!e.readOnly &&
|
|
560
|
-
e.getAttribute('aria-readonly') !== 'true' &&
|
|
561
|
-
(['textbox', 'searchbox', 'spinbutton'].includes(rname) ||
|
|
562
|
-
(rname === 'combobox' && ['INPUT', 'TEXTAREA'].includes(e.tagName)));
|
|
563
|
-
// reveal() is null for <li>, <progress> and <meter>, whose numeric `value`s are not field contents.
|
|
564
|
-
base.value = reveal(source) ?? (e.isContentEditable || rname === 'combobox' ? e.innerText.trim() : null);
|
|
565
|
-
base.operations = editable ? ['fill', 'click', 'enter'] : ['click'];
|
|
566
|
-
// Only an element that names itself draggable is offered as a drag source: `draggable` reports true for
|
|
567
|
-
// every <a> and <img> by default, which would offer the whole page as draggable.
|
|
568
|
-
if (e.getAttribute('draggable') === 'true') base.operations.push('drag');
|
|
569
|
-
}
|
|
570
|
-
controls.push(base);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Controls that read the same cannot be told apart by their label, and a hint naming the right one
|
|
574
|
-
// ("Add to cart under Sauce Labs Backpack") has nothing to match on. Give each twin the widest ancestor
|
|
575
|
-
// that holds it and none of its twins, named by that ancestor's heading or its own first line of text.
|
|
576
|
-
// Only labels that actually collide pay for it.
|
|
577
|
-
const HEADINGS = 'h1,h2,h3,h4,h5,h6,[role="heading"],legend,caption,th,dt,summary';
|
|
578
|
-
const firstLine = text =>
|
|
579
|
-
(text || '')
|
|
580
|
-
.split('\n')
|
|
581
|
-
.map(s => s.replace(/\s+/g, ' ').trim())
|
|
582
|
-
.find(Boolean) || '';
|
|
583
|
-
// A label tied to no control titles what follows it, as a heading would: a blog post's "Date Picker 3" is one,
|
|
584
|
-
// and the post's first heading ("Data Entry Form") named that picker's Submit when only headings counted.
|
|
585
|
-
// A hidden title names nothing on screen: innerText still returns a `hidden` label's text.
|
|
586
|
-
const titles = memo(scope =>
|
|
587
|
-
[...scope.querySelectorAll(`${HEADINGS},label`)].filter(
|
|
588
|
-
e =>
|
|
589
|
-
(e.localName !== 'label' || !e.control) && e.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }),
|
|
590
|
-
));
|
|
591
|
-
// A table's header cell names its own row, and a caption its own table, never what sits outside them: a jQuery UI
|
|
592
|
-
// datepicker's Next button was named "Su", its weekday header, and every month's Next read the same.
|
|
593
|
-
const reaches = (title, element) =>
|
|
594
|
-
!['th', 'caption'].includes(title.localName) || title.parentElement.contains(element);
|
|
595
|
-
// The title nearest before the element names its section; the scope's first title is the fallback, and the
|
|
596
|
-
// only answer when `nearest` is false.
|
|
597
|
-
const sectionOf = (scope, element, label, nearest = true) => {
|
|
598
|
-
let first = '',
|
|
599
|
-
before = '';
|
|
600
|
-
for (const title of titles(scope)) {
|
|
601
|
-
const text = firstLine(innerTextOf(title));
|
|
602
|
-
if (!text || text === label || title.contains(element) || !reaches(title, element)) continue;
|
|
603
|
-
first ||= text;
|
|
604
|
-
if (title.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) before = text;
|
|
605
|
-
}
|
|
606
|
-
return (nearest && before) || first;
|
|
607
|
-
};
|
|
608
|
-
// Labels more than one control shares, filled in once every control is listed.
|
|
609
|
-
const repeated = new Set();
|
|
610
|
-
const nameOf = (scope, element, label, nearest) => {
|
|
611
|
-
const aria = (scope.getAttribute('aria-label') || '').trim();
|
|
612
|
-
if (aria && aria !== label) return aria;
|
|
613
|
-
const section = sectionOf(scope, element, label, nearest);
|
|
614
|
-
if (section) return section;
|
|
615
|
-
// A line that is itself a repeated control's label ("Prev") reads the same in every repeat, so it names none:
|
|
616
|
-
// past it, a datepicker's first line is the month it shows. The label is cut out as whole words, since a day
|
|
617
|
-
// labelled "2" cut out of "October 2026" left "October 0 6".
|
|
618
|
-
const lines = (innerTextOf(scope) || '')
|
|
619
|
-
.split('\n')
|
|
620
|
-
.map(line => line.replace(/\s+/g, ' ').trim())
|
|
621
|
-
.filter(line => line && !repeated.has(line));
|
|
622
|
-
return firstLine(lines.map(line => (label ? ` ${line} `.split(` ${label} `).join(' ') : line)).join('\n'));
|
|
623
|
-
};
|
|
624
|
-
const contextOf = (element, holding, label, nearest = true) => {
|
|
625
|
-
// The widest twin-free ancestor is the card, row or section the twins repeat over. A narrower one names
|
|
626
|
-
// the button's own wrapper, which on a shop is its price rather than the product it belongs to.
|
|
627
|
-
let scope = null;
|
|
628
|
-
for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
|
|
629
|
-
if (holding.get(e) > 1) break;
|
|
630
|
-
scope = e;
|
|
631
|
-
// A lone open reply has no form twin nearby; crossing its record picks a different commenter's name.
|
|
632
|
-
if (e.matches('article,li,[role="listitem"]') ||
|
|
633
|
-
(e.matches('tr,[role="row"]') && e.querySelector('input,textarea,select,[role="textbox"]'))) break;
|
|
634
|
-
}
|
|
635
|
-
return scope ? excerpt(nameOf(scope, element, label, nearest), CONTROL_CONTEXT_CHARS) : '';
|
|
636
|
-
};
|
|
637
|
-
// Content a stylesheet shows only under the pointer (`.card:hover .caption`) is out of reach of every other
|
|
638
|
-
// operation. An element whose hover rule would reveal something now hidden is offered as a hover target.
|
|
639
|
-
const docs = [];
|
|
640
|
-
const addDoc = doc => {
|
|
641
|
-
if (!doc?.body || !granted(doc) || docs.includes(doc)) return;
|
|
642
|
-
docs.push(doc);
|
|
643
|
-
for (const frame of doc.querySelectorAll('iframe,frame')) {
|
|
644
|
-
try {
|
|
645
|
-
addDoc(frame.contentDocument);
|
|
646
|
-
} catch {
|
|
647
|
-
/* A cross-origin frame is observed in its own session. */
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
addDoc(document);
|
|
652
|
-
// Parsing every stylesheet each step is the slow part on a large site, so a document's hover rules are kept
|
|
653
|
-
// until its rule counts change (a new sheet, or rules a script inserted).
|
|
654
|
-
registry.hoverRules ||= new WeakMap();
|
|
655
|
-
const revealsIn = doc => {
|
|
656
|
-
const sheets = [];
|
|
657
|
-
for (const sheet of doc.styleSheets) {
|
|
658
|
-
try {
|
|
659
|
-
sheets.push(sheet.cssRules);
|
|
660
|
-
} catch {
|
|
661
|
-
/* A cross-origin stylesheet cannot be read. */
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
const version = sheets.map(rules => rules.length).join(',');
|
|
665
|
-
const cached = registry.hoverRules.get(doc);
|
|
666
|
-
if (cached?.version === version) return cached.reveals;
|
|
667
|
-
const reveals = new Map();
|
|
668
|
-
const collect = rules => {
|
|
669
|
-
for (const rule of rules) {
|
|
670
|
-
if (rule.cssRules?.length) collect(rule.cssRules);
|
|
671
|
-
for (const selector of (rule.selectorText || '').split(',')) {
|
|
672
|
-
// Split `a:hover b` into the hovered compound and what it reveals inside it. Rules that restyle the
|
|
673
|
-
// hovered element itself, or reveal a sibling or a pseudo-element, have nothing to offer here.
|
|
674
|
-
const m = selector.match(/^(.*?):hover([^\s>+~]*)\s*(>?)\s*([^+~]*)$/);
|
|
675
|
-
if (!m || !m[1].trim() || !m[4].trim() || m[4].includes('::')) continue;
|
|
676
|
-
const pair = [
|
|
677
|
-
(m[1] + m[2]).replaceAll(':hover', '').trim(),
|
|
678
|
-
`:scope ${m[3]} ${m[4].replaceAll(':hover', '')}`,
|
|
679
|
-
];
|
|
680
|
-
reveals.set(pair.join('\n'), pair);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
};
|
|
684
|
-
for (const rules of sheets) collect(rules);
|
|
685
|
-
const found = [...reveals.values()].slice(0, MAX_HOVER_RULES);
|
|
686
|
-
registry.hoverRules.set(doc, { version, reveals: found });
|
|
687
|
-
return found;
|
|
688
|
-
};
|
|
689
|
-
const offered = new Set(controls.map(c => registry.nodes.get(c.id)));
|
|
690
|
-
// Bounded work: each candidate costs a query, and a stylesheet can name thousands of hoverable elements.
|
|
691
|
-
let hovers = 0,
|
|
692
|
-
checked = 0;
|
|
693
|
-
for (const doc of docs) {
|
|
694
|
-
for (const [base, tail] of revealsIn(doc)) {
|
|
695
|
-
let hosts = [];
|
|
696
|
-
try {
|
|
697
|
-
hosts = doc.querySelectorAll(base);
|
|
698
|
-
} catch {
|
|
699
|
-
continue;
|
|
700
|
-
}
|
|
701
|
-
for (const e of hosts) {
|
|
702
|
-
if (hovers >= MAX_HOVER_TARGETS || checked >= MAX_HOVER_CHECKS) break;
|
|
703
|
-
// A link or button's own hover rule restyles it or shows decoration; offering HOVER there as well as
|
|
704
|
-
// CLICK only gives the choice another way to be wrong.
|
|
705
|
-
if (offered.has(e) || e === doc.body || e === doc.documentElement || !visible(e)) continue;
|
|
706
|
-
const r = e.getBoundingClientRect(),
|
|
707
|
-
x = r.x + r.width / 2,
|
|
708
|
-
y = r.y + r.height / 2;
|
|
709
|
-
if (r.width <= 0 || r.height <= 0 || x < 0 || x >= innerWidth || r.height > innerHeight) continue;
|
|
710
|
-
checked++;
|
|
711
|
-
let hidden = false;
|
|
712
|
-
try {
|
|
713
|
-
hidden = [...e.querySelectorAll(tail)].some(
|
|
714
|
-
t => !visible(t) && (t.textContent.trim() || t.querySelector('img,a')),
|
|
715
|
-
);
|
|
716
|
-
} catch {
|
|
717
|
-
continue;
|
|
718
|
-
}
|
|
719
|
-
// Removing the active hover target renumbers its identical siblings and sends the next choice
|
|
720
|
-
// to a different element. Keep it offered while the pointer still reveals its content.
|
|
721
|
-
if (!hidden && !e.matches(':hover')) continue;
|
|
722
|
-
const image = e.querySelector('img[alt]');
|
|
723
|
-
const label =
|
|
724
|
-
e.getAttribute('aria-label') ||
|
|
725
|
-
image?.getAttribute('alt') ||
|
|
726
|
-
firstLine(e.innerText) ||
|
|
727
|
-
e.getAttribute('title') ||
|
|
728
|
-
e.tagName.toLowerCase();
|
|
729
|
-
const role =
|
|
730
|
-
e.getAttribute('role') ||
|
|
731
|
-
{ FIGURE: 'figure', LI: 'listitem', IMG: 'img', TR: 'row', TD: 'cell' }[e.tagName] ||
|
|
732
|
-
'group';
|
|
733
|
-
const c = {
|
|
734
|
-
id: identity(e),
|
|
735
|
-
role,
|
|
736
|
-
label: excerpt(label, HOVER_LABEL_CHARS),
|
|
737
|
-
offscreen: y < 0 || y >= innerHeight,
|
|
738
|
-
distance: y < 0 || y >= innerHeight ? 1 + Math.abs(y - innerHeight / 2) : 0,
|
|
739
|
-
sensitive: false,
|
|
740
|
-
input_type: null,
|
|
741
|
-
frame_origin: e.ownerDocument.location.origin,
|
|
742
|
-
frame_path: framePath(e.ownerDocument),
|
|
743
|
-
submit_semantics: null,
|
|
744
|
-
value: null,
|
|
745
|
-
operations: ['hover'],
|
|
746
|
-
};
|
|
747
|
-
controls.push(c);
|
|
748
|
-
offered.add(e);
|
|
749
|
-
hovers++;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
const byLabel = new Map();
|
|
755
|
-
for (const c of controls) {
|
|
756
|
-
const key = JSON.stringify([c.role, c.label]);
|
|
757
|
-
if (!byLabel.has(key)) byLabel.set(key, []);
|
|
758
|
-
byLabel.get(key).push(c);
|
|
759
|
-
}
|
|
760
|
-
for (const group of byLabel.values()) if (group.length > 1) repeated.add(group[0].label);
|
|
761
|
-
for (const group of byLabel.values()) {
|
|
762
|
-
if (group.length < 2) continue;
|
|
763
|
-
const twins = group.map(c => registry.nodes.get(c.id));
|
|
764
|
-
// How many twins each ancestor holds. One that holds two is past the card, and counting once beats asking
|
|
765
|
-
// every ancestor of every twin whether it contains each of the others.
|
|
766
|
-
const holding = new Map();
|
|
767
|
-
for (const twin of twins) {
|
|
768
|
-
for (let e = twin.parentElement; e; e = e.parentElement) holding.set(e, (holding.get(e) || 0) + 1);
|
|
769
|
-
}
|
|
770
|
-
const nearest = group.map(c => contextOf(registry.nodes.get(c.id), holding, c.label));
|
|
771
|
-
group.forEach((c, i) => {
|
|
772
|
-
// Cards that each end in an "Options" heading share their nearest title, which would name every twin
|
|
773
|
-
// alike; a twin whose nearest title another twin shares is named by its card's first title instead.
|
|
774
|
-
const shared = nearest.indexOf(nearest[i]) !== nearest.lastIndexOf(nearest[i]);
|
|
775
|
-
const context = shared ? contextOf(registry.nodes.get(c.id), holding, c.label, false) : nearest[i];
|
|
776
|
-
if (context) c.context = context;
|
|
777
|
-
});
|
|
778
|
-
// Options that are direct children of one list share every ancestor, and their aria-label hides the text
|
|
779
|
-
// inside them, so "Springfield" twice read alike even when one says "City in Illinois" and the other
|
|
780
|
-
// "State park in Oregon". A twin with no context is told apart by what it shows besides its label, when
|
|
781
|
-
// that differs.
|
|
782
|
-
// Inline children join into one run in innerText, so each text node is read as a line of its own.
|
|
783
|
-
const ownOf = (element, label) => {
|
|
784
|
-
const lines = [];
|
|
785
|
-
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
786
|
-
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
787
|
-
const line = node.data.replace(/\s+/g, ' ').trim();
|
|
788
|
-
// A caption a hover rule reveals is not shown, so it cannot tell twins apart.
|
|
789
|
-
if (!line || line === label || repeated.has(line)) continue;
|
|
790
|
-
if (!node.parentElement?.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue;
|
|
791
|
-
lines.push(` ${line} `.split(` ${label} `).join(' '));
|
|
792
|
-
}
|
|
793
|
-
return excerpt(firstLine(lines.join('\n')), CONTROL_CONTEXT_CHARS);
|
|
794
|
-
};
|
|
795
|
-
const own = group.map(c => (c.context ? '' : ownOf(registry.nodes.get(c.id), c.label)));
|
|
796
|
-
if (new Set(own.filter(Boolean)).size > 1 || (own.some(Boolean) && group.some(c => c.context))) {
|
|
797
|
-
group.forEach((c, i) => {
|
|
798
|
-
if (own[i]) c.context = own[i];
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
// Twins whose surroundings name none of them, like a row of identical avatars, still differ by position.
|
|
802
|
-
if (group.every(c => !c.context)) {
|
|
803
|
-
twins.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
|
804
|
-
for (const c of group) {
|
|
805
|
-
c.context = `${twins.indexOf(registry.nodes.get(c.id)) + 1} of ${twins.length}`;
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
// An unnamed control the page draws once per card, a trash icon beside each image, is a twin only while another
|
|
811
|
-
// card shows the same icon: one left alone, or drawn by another icon set, would name no card and leave the choice
|
|
812
|
-
// guessing which image it removes. It is named by the card that holds it, or failing one by the nearest ancestor
|
|
813
|
-
// that says anything besides it.
|
|
814
|
-
// A card is an item, a row, or an element the page repeats beside itself with the same tag and class.
|
|
815
|
-
const isCard = e =>
|
|
816
|
-
e.matches('article,li,[role="listitem"],tr,[role="row"]') ||
|
|
817
|
-
(!!e.getAttribute('class') &&
|
|
818
|
-
[...(e.parentElement?.children || [])].some(
|
|
819
|
-
s => s !== e && s.localName === e.localName && s.getAttribute('class') === e.getAttribute('class'),
|
|
820
|
-
));
|
|
821
|
-
const nearestName = (element, label) => {
|
|
822
|
-
let first = '';
|
|
823
|
-
for (let e = element.parentElement, path = element; e && e !== e.ownerDocument.body; path = e, e = e.parentElement) {
|
|
824
|
-
first ||= nameOf(e, element, label, true);
|
|
825
|
-
if (!isCard(e)) continue;
|
|
826
|
-
// An action row's own caption ("Actions") is the first text the climb meets and names no item, so the card
|
|
827
|
-
// is named by what it holds outside the row that holds the control.
|
|
828
|
-
const aria = (e.getAttribute('aria-label') || '').trim();
|
|
829
|
-
const outside = [...e.children]
|
|
830
|
-
.filter(child => child !== path)
|
|
831
|
-
.map(child => firstLine(innerTextOf(child)))
|
|
832
|
-
.find(line => line && line !== label && !repeated.has(line));
|
|
833
|
-
return excerpt((aria !== label && aria) || outside || first, CONTROL_CONTEXT_CHARS);
|
|
834
|
-
}
|
|
835
|
-
return excerpt(first, CONTROL_CONTEXT_CHARS);
|
|
836
|
-
};
|
|
837
|
-
for (const c of controls) {
|
|
838
|
-
if (!c.context && unnamed.has(c.id)) {
|
|
839
|
-
const context = nearestName(registry.nodes.get(c.id), c.label);
|
|
840
|
-
if (context) c.context = context;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// A revealed caption needs its actual container association, not merely the most recent action.
|
|
845
|
-
registry.hoverContexts = new WeakMap();
|
|
846
|
-
for (const c of controls) {
|
|
847
|
-
if (!c.operations.includes('hover')) continue;
|
|
848
|
-
const element = registry.nodes.get(c.id);
|
|
849
|
-
registry.hoverContexts.set(element, {
|
|
850
|
-
control: {
|
|
851
|
-
role: c.role, label: c.label,
|
|
852
|
-
context: /^\d+ of \d+$/.test(c.context || '') ? c.context : null,
|
|
853
|
-
},
|
|
854
|
-
labelAttributes: [element.getAttribute('role'), element.getAttribute('aria-label'),
|
|
855
|
-
element.getAttribute('title'), element.querySelector('img[alt]')?.getAttribute('alt') || null],
|
|
856
|
-
text: element.innerText,
|
|
857
|
-
parent: element.parentNode,
|
|
858
|
-
siblings: [...(element.parentNode?.children || [])],
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// A date field's own label ("Start Date") rarely says which picker it belongs to when a page offers several
|
|
863
|
-
// ("Date Picker 3"): unlike a colliding label, a uniquely named one never reaches the loop above, so the
|
|
864
|
-
// nearest ancestor that names a section is attached here on its own, without the twin-avoidance `contextOf`
|
|
865
|
-
// needs to keep a card from naming its neighbour.
|
|
866
|
-
const nearestHeading = (element, label) => {
|
|
867
|
-
for (let e = element.parentElement; e && e !== e.ownerDocument.body; e = e.parentElement) {
|
|
868
|
-
const aria = (e.getAttribute('aria-label') || '').trim();
|
|
869
|
-
if (aria && aria !== label) return excerpt(aria, CONTROL_CONTEXT_CHARS);
|
|
870
|
-
const section = sectionOf(e, element, label);
|
|
871
|
-
if (section) return excerpt(section, CONTROL_CONTEXT_CHARS);
|
|
872
|
-
}
|
|
873
|
-
return '';
|
|
874
|
-
};
|
|
875
|
-
for (const c of controls) {
|
|
876
|
-
if (!c.context && DATE_TYPES.includes(c.input_type)) {
|
|
877
|
-
const context = nearestHeading(registry.nodes.get(c.id), c.label);
|
|
878
|
-
if (context) c.context = context;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
// Flattened text loses empty cells: a calendar day then has no weekday, and the chooser picked a Tuesday
|
|
883
|
-
// for the first Friday. Keep the table's column names beside its controls, including uniquely named ones.
|
|
884
|
-
const columns = new Map();
|
|
885
|
-
for (const c of controls) {
|
|
886
|
-
const cell = registry.nodes.get(c.id).closest('td,th');
|
|
887
|
-
const table = cell?.closest('table');
|
|
888
|
-
if (!table) continue;
|
|
889
|
-
if (!columns.has(table)) {
|
|
890
|
-
const positions = new Map(),
|
|
891
|
-
headers = [],
|
|
892
|
-
occupied = [];
|
|
893
|
-
const rows = [...table.rows];
|
|
894
|
-
rows.forEach((row, r) => {
|
|
895
|
-
let col = 0;
|
|
896
|
-
for (const cell of row.cells) {
|
|
897
|
-
while (occupied[col] > r) col++;
|
|
898
|
-
const end = col + cell.colSpan;
|
|
899
|
-
positions.set(cell, [col, end, r]);
|
|
900
|
-
const remaining = row.parentElement.rows.length - row.sectionRowIndex;
|
|
901
|
-
for (let i = col; i < end; i++) occupied[i] = r + Math.min(cell.rowSpan || remaining, remaining);
|
|
902
|
-
if (
|
|
903
|
-
cell.tagName === 'TH' &&
|
|
904
|
-
!['row', 'rowgroup'].includes(cell.scope) &&
|
|
905
|
-
(cell.scope ||
|
|
906
|
-
row.parentElement.tagName === 'THEAD' ||
|
|
907
|
-
(r === 0 && [...row.cells].every(c => c.tagName === 'TH')))
|
|
908
|
-
)
|
|
909
|
-
headers.push(cell);
|
|
910
|
-
col = end;
|
|
911
|
-
}
|
|
912
|
-
});
|
|
913
|
-
const names = new Map();
|
|
914
|
-
for (const [cell, [start, end, row]] of positions) {
|
|
915
|
-
const associated = cell.headers
|
|
916
|
-
? cell.headers.split(/\s+/).map(id => cell.ownerDocument.getElementById(id))
|
|
917
|
-
: headers.filter(header => {
|
|
918
|
-
const [left, right, above] = positions.get(header);
|
|
919
|
-
return above < row && left < end && right > start;
|
|
920
|
-
});
|
|
921
|
-
const labels = associated
|
|
922
|
-
.filter(header => header && visible(header))
|
|
923
|
-
.map(
|
|
924
|
-
header =>
|
|
925
|
-
header.getAttribute('abbr') ||
|
|
926
|
-
header.getAttribute('title') ||
|
|
927
|
-
header.querySelector('[title]')?.getAttribute('title') ||
|
|
928
|
-
labelOf(header),
|
|
929
|
-
);
|
|
930
|
-
names.set(cell, [...new Set(labels.filter(Boolean))].join(' / '));
|
|
931
|
-
}
|
|
932
|
-
columns.set(table, names);
|
|
933
|
-
}
|
|
934
|
-
const column = columns.get(table).get(cell);
|
|
935
|
-
if (column) c.context = [c.context, `column: ${excerpt(column, CONTROL_CONTEXT_CHARS)}`].filter(Boolean).join('; ');
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// Python applies the configured caps after merging frames; keep the nearest controls first.
|
|
939
|
-
controls.sort((a, b) => a.distance - b.distance);
|
|
940
|
-
|
|
941
|
-
// Text in a same-process child document is on screen as much as the parent's: a frameset page has no text of
|
|
942
|
-
// its own at all. Each child is read in its own coordinates, shifted by where its frame sits.
|
|
943
|
-
const words = [];
|
|
944
|
-
// Geometry, the cheaper test, goes first.
|
|
945
|
-
const readText = (doc, dx, dy) => {
|
|
946
|
-
if (!doc.body) return;
|
|
947
|
-
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
|
948
|
-
const range = doc.createRange();
|
|
949
|
-
let node;
|
|
950
|
-
while ((node = walker.nextNode())) {
|
|
951
|
-
const value = node.textContent.trim(),
|
|
952
|
-
parent = node.parentElement;
|
|
953
|
-
if (!value || !parent) continue;
|
|
954
|
-
range.selectNodeContents(node);
|
|
955
|
-
const r = range.getBoundingClientRect();
|
|
956
|
-
const top = r.top + dy,
|
|
957
|
-
left = r.left + dx;
|
|
958
|
-
if (
|
|
959
|
-
r.width > 0 &&
|
|
960
|
-
r.height > 0 &&
|
|
961
|
-
top + r.height > 0 &&
|
|
962
|
-
top < innerHeight &&
|
|
963
|
-
left + r.width > 0 &&
|
|
964
|
-
left < innerWidth &&
|
|
965
|
-
!parent.closest('script,style,noscript,template') &&
|
|
966
|
-
visible(parent)
|
|
967
|
-
) {
|
|
968
|
-
words.push(value);
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
for (const frame of doc.querySelectorAll('iframe,frame')) {
|
|
972
|
-
let inner = null;
|
|
973
|
-
try {
|
|
974
|
-
inner = frame.contentDocument;
|
|
975
|
-
} catch {
|
|
976
|
-
inner = null;
|
|
977
|
-
}
|
|
978
|
-
if (!inner || !granted(inner)) continue;
|
|
979
|
-
const f = frame.getBoundingClientRect();
|
|
980
|
-
readText(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
|
|
981
|
-
}
|
|
982
|
-
};
|
|
983
|
-
readText(document, 0, 0);
|
|
984
|
-
const viewport_text = words.join('\n');
|
|
985
|
-
|
|
986
|
-
// Page state carried by pixels: a chart, a picture, a drawing or a video is evidence the page text and the
|
|
987
|
-
// control list cannot hold, and the verifier needs the screenshot for it. Only what the viewport shows counts,
|
|
988
|
-
// because that is what a screenshot captures: an image below the fold cannot appear in it, and counting every
|
|
989
|
-
// image on a long page would ask for a screenshot on almost every page and undo the gate it exists to serve.
|
|
990
|
-
// Geometry is read in the parent's coordinates the way readText reads text, so a frame's image counts where the
|
|
991
|
-
// frame itself sits on screen. Elements small enough to be an icon or a rule are not page state.
|
|
992
|
-
const VISUAL = 'img,canvas,video,svg,picture,figure,[role="img"]';
|
|
993
|
-
const VISUAL_MIN_PX = 48;
|
|
994
|
-
let visual_content = 0;
|
|
995
|
-
const readVisual = (doc, dx, dy) => {
|
|
996
|
-
if (visual_content) return;
|
|
997
|
-
for (const e of doc.querySelectorAll(VISUAL)) {
|
|
998
|
-
if (e.closest('[aria-hidden="true"],[inert]') || !visible(e)) continue;
|
|
999
|
-
const r = e.getBoundingClientRect();
|
|
1000
|
-
const top = r.top + dy,
|
|
1001
|
-
left = r.left + dx;
|
|
1002
|
-
if (r.width < VISUAL_MIN_PX || r.height < VISUAL_MIN_PX) continue;
|
|
1003
|
-
if (top + r.height <= 0 || top >= innerHeight || left + r.width <= 0 || left >= innerWidth) continue;
|
|
1004
|
-
visual_content = 1;
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
for (const frame of doc.querySelectorAll('iframe,frame')) {
|
|
1008
|
-
let inner = null;
|
|
1009
|
-
try {
|
|
1010
|
-
inner = frame.contentDocument;
|
|
1011
|
-
} catch {
|
|
1012
|
-
inner = null;
|
|
1013
|
-
}
|
|
1014
|
-
if (!inner || !granted(inner)) continue;
|
|
1015
|
-
const f = frame.getBoundingClientRect();
|
|
1016
|
-
readVisual(inner, dx + f.left + frame.clientLeft, dy + f.top + frame.clientTop);
|
|
1017
|
-
}
|
|
1018
|
-
};
|
|
1019
|
-
readVisual(document, 0, 0);
|
|
1020
|
-
|
|
1021
|
-
const guards = {},
|
|
1022
|
-
retarget_keys = {};
|
|
1023
|
-
for (const c of controls) {
|
|
1024
|
-
const guard = registry.guard(registry.nodes.get(c.id));
|
|
1025
|
-
guards[c.id] = guard && digest(JSON.stringify(guard));
|
|
1026
|
-
// A redraw changes the node ids at either end of the guard. Everything between them, including the receiving
|
|
1027
|
-
// document's timeOrigin and form semantics, must survive before an action can follow it.
|
|
1028
|
-
retarget_keys[c.id] = guard && digest(JSON.stringify(guard.slice(1, -1)));
|
|
1029
|
-
}
|
|
1030
|
-
// A dependent field can appear without changing page text. Compare all controls,
|
|
1031
|
-
// excusing only the value and required marker of the field just filled.
|
|
1032
|
-
const formState = controls.map(({ distance, offscreen, ...c }) => {
|
|
1033
|
-
const form = registry.nodes.get(c.id)?.form;
|
|
1034
|
-
return { ...c, form: form ? [form.action, form.method, form.target] : null };
|
|
1035
|
-
});
|
|
1036
|
-
const formPage = JSON.stringify([location.href, performance.timeOrigin, document.title, viewport_text]);
|
|
1037
|
-
if (typeof mode === 'object') {
|
|
1038
|
-
const previous = registry.formObserved;
|
|
1039
|
-
if (!previous || previous.page !== formPage) return false;
|
|
1040
|
-
const expected = previous.controls.map(c => {
|
|
1041
|
-
if (c.id !== mode.id) return c;
|
|
1042
|
-
const { blocking, ...filled } = c;
|
|
1043
|
-
return mode.text ? { ...filled, value: mode.text } : c;
|
|
1044
|
-
});
|
|
1045
|
-
const same = JSON.stringify(expected) === JSON.stringify(formState);
|
|
1046
|
-
if (same) registry.formObserved = { page: formPage, controls: formState };
|
|
1047
|
-
return same;
|
|
1048
|
-
}
|
|
1049
|
-
registry.formObserved = { page: formPage, controls: formState };
|
|
1050
|
-
// Kept so a later check can ask whether any of these controls changed without sending the guards back.
|
|
1051
|
-
registry.observed = new Map(controls.map(c => [c.id, guards[c.id]]));
|
|
1052
|
-
const field_state = [...document.querySelectorAll('input,textarea,select')]
|
|
1053
|
-
.filter(safe)
|
|
1054
|
-
.map(e => [identity(e), reveal(e), e.checked, e.selectedIndex, e.disabled, e.readOnly]);
|
|
1055
|
-
// Compare meaning and identity for the page_key fingerprint; geometry is re-resolved just before input.
|
|
1056
|
-
const semantics = controls.map(({ distance, ...c }) => c);
|
|
1057
|
-
const page_key = digest(JSON.stringify([
|
|
1058
|
-
location.href,
|
|
1059
|
-
scrollX,
|
|
1060
|
-
scrollY,
|
|
1061
|
-
innerWidth,
|
|
1062
|
-
innerHeight,
|
|
1063
|
-
document.title,
|
|
1064
|
-
viewport_text,
|
|
1065
|
-
semantics,
|
|
1066
|
-
field_state,
|
|
1067
|
-
]));
|
|
1068
|
-
|
|
1069
|
-
const result = {
|
|
1070
|
-
url: location.href,
|
|
1071
|
-
title: document.title,
|
|
1072
|
-
viewport_text,
|
|
1073
|
-
visual_content,
|
|
1074
|
-
document_key: String(performance.timeOrigin),
|
|
1075
|
-
controls: semantics,
|
|
1076
|
-
page_key,
|
|
1077
|
-
guards,
|
|
1078
|
-
retarget_keys,
|
|
1079
|
-
inaccessible_frames: inaccessible,
|
|
1080
|
-
};
|
|
1081
|
-
if (!limits) return result;
|
|
1082
|
-
// Python keeps at most `limits.onscreen` of the controls on screen and `limits.offscreen` of those off it, each
|
|
1083
|
-
// the first in this order, and any pager past either cap. A control past the caps is sent with only what decides
|
|
1084
|
-
// whether it is a pager; Python asks for the rest of one it keeps, by this snapshot's token, from what is kept here.
|
|
1085
|
-
const token = (registry.snapshots = (registry.snapshots || 0) + 1);
|
|
1086
|
-
registry.sent = { token, rows: new Map(semantics.map(c => [c.id, [c, guards[c.id], retarget_keys[c.id]]])) };
|
|
1087
|
-
let onscreen = 0,
|
|
1088
|
-
offscreen = 0;
|
|
1089
|
-
result.controls = semantics.map(c => {
|
|
1090
|
-
if (c.offscreen ? offscreen++ < limits.offscreen : onscreen++ < limits.onscreen) return c;
|
|
1091
|
-
delete result.guards[c.id];
|
|
1092
|
-
delete result.retarget_keys[c.id];
|
|
1093
|
-
const { id, role, label, operations, href, next_page } = c;
|
|
1094
|
-
return { id, role, label, operations, href, next_page, offscreen: c.offscreen, partial: true };
|
|
1095
|
-
});
|
|
1096
|
-
result.token = token;
|
|
1097
|
-
return result;
|
|
1098
|
-
})
|