@geometra/proxy 1.64.0 → 1.65.1
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 +10 -1
- package/dist/a11y-enrich.d.ts +4 -2
- package/dist/a11y-enrich.d.ts.map +1 -1
- package/dist/a11y-enrich.js +204 -35
- package/dist/a11y-enrich.js.map +1 -1
- package/dist/dom-actions.d.ts +3 -1
- package/dist/dom-actions.d.ts.map +1 -1
- package/dist/dom-actions.js +1483 -328
- package/dist/dom-actions.js.map +1 -1
- package/dist/extractor.d.ts +9 -0
- package/dist/extractor.d.ts.map +1 -1
- package/dist/extractor.js +877 -63
- package/dist/extractor.js.map +1 -1
- package/dist/geometry-ws.d.ts +34 -4
- package/dist/geometry-ws.d.ts.map +1 -1
- package/dist/geometry-ws.js +884 -91
- package/dist/geometry-ws.js.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -1
- package/dist/runtime.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +120 -37
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
package/dist/extractor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { performance } from 'node:perf_hooks';
|
|
2
|
-
import { enrichSnapshotWithCdpAx, shouldEnrichSnapshotWithCdpAx, } from './a11y-enrich.js';
|
|
3
|
+
import { createCdpAxSessionManager, enrichSnapshotWithCdpAx, shouldEnrichSnapshotWithCdpAx, } from './a11y-enrich.js';
|
|
3
4
|
import { frameOriginInRootPage } from './frame-offset.js';
|
|
4
5
|
import { offsetLayoutSubtree } from './layout-offset.js';
|
|
5
6
|
/**
|
|
@@ -8,6 +9,9 @@ import { offsetLayoutSubtree } from './layout-offset.js';
|
|
|
8
9
|
function browserExtractGeometry() {
|
|
9
10
|
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template']);
|
|
10
11
|
const LEAF_FORM_TAGS = new Set(['input', 'textarea', 'select']);
|
|
12
|
+
const CLOSED_SHADOW_ROOTS_SYMBOL = Symbol.for('geometra.closedShadowRoots');
|
|
13
|
+
const IFRAME_IDENTITIES_SYMBOL = Symbol.for('geometra.iframeIdentities');
|
|
14
|
+
const fileDescendantCache = new WeakMap();
|
|
11
15
|
const FORM_CONTROL_ROLES = new Set([
|
|
12
16
|
'button',
|
|
13
17
|
'checkbox',
|
|
@@ -20,6 +24,87 @@ function browserExtractGeometry() {
|
|
|
20
24
|
'switch',
|
|
21
25
|
'textbox',
|
|
22
26
|
]);
|
|
27
|
+
function registeredShadowRootForHost(host) {
|
|
28
|
+
const openRoot = host.shadowRoot;
|
|
29
|
+
if (openRoot)
|
|
30
|
+
return openRoot;
|
|
31
|
+
try {
|
|
32
|
+
const registry = globalThis[CLOSED_SHADOW_ROOTS_SYMBOL];
|
|
33
|
+
if (!(registry instanceof WeakMap))
|
|
34
|
+
return null;
|
|
35
|
+
const root = registry.get(host);
|
|
36
|
+
return root instanceof ShadowRoot ? root : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function rootScopedElementById(owner, id) {
|
|
43
|
+
const root = owner.getRootNode();
|
|
44
|
+
if (root instanceof ShadowRoot)
|
|
45
|
+
return root.getElementById(id);
|
|
46
|
+
if (root instanceof Document)
|
|
47
|
+
return root.getElementById(id);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function closedShadowRootForElement(el) {
|
|
51
|
+
const root = el.getRootNode();
|
|
52
|
+
return root instanceof ShadowRoot && root.mode === 'closed' ? root : null;
|
|
53
|
+
}
|
|
54
|
+
function iframeIdentityForElement(el) {
|
|
55
|
+
try {
|
|
56
|
+
const registry = globalThis[IFRAME_IDENTITIES_SYMBOL];
|
|
57
|
+
if (!(registry instanceof WeakMap))
|
|
58
|
+
return undefined;
|
|
59
|
+
const identity = registry.get(el);
|
|
60
|
+
return typeof identity === 'string' && identity ? identity : undefined;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function isClosedShadowFormControl(el) {
|
|
67
|
+
if (!closedShadowRootForElement(el))
|
|
68
|
+
return false;
|
|
69
|
+
if (el instanceof HTMLInputElement ||
|
|
70
|
+
el instanceof HTMLTextAreaElement ||
|
|
71
|
+
el instanceof HTMLSelectElement)
|
|
72
|
+
return true;
|
|
73
|
+
if (el instanceof HTMLElement && el.isContentEditable)
|
|
74
|
+
return true;
|
|
75
|
+
const role = el.getAttribute('role')?.trim().toLowerCase();
|
|
76
|
+
return !!role && role !== 'button' && FORM_CONTROL_ROLES.has(role);
|
|
77
|
+
}
|
|
78
|
+
function subtreeContainsExtractableFileInput(container) {
|
|
79
|
+
const cached = fileDescendantCache.get(container);
|
|
80
|
+
if (cached !== undefined)
|
|
81
|
+
return cached;
|
|
82
|
+
if (container instanceof HTMLInputElement && container.type === 'file') {
|
|
83
|
+
fileDescendantCache.set(container, true);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (container.querySelector('input[type="file"]')) {
|
|
87
|
+
fileDescendantCache.set(container, true);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
for (const host of [container, ...container.querySelectorAll('*')]) {
|
|
91
|
+
const root = registeredShadowRootForHost(host);
|
|
92
|
+
if (root?.mode !== 'open')
|
|
93
|
+
continue;
|
|
94
|
+
if (root.querySelector('input[type="file"]')) {
|
|
95
|
+
fileDescendantCache.set(container, true);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
for (const shadowChild of root.children) {
|
|
99
|
+
if (subtreeContainsExtractableFileInput(shadowChild)) {
|
|
100
|
+
fileDescendantCache.set(container, true);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
fileDescendantCache.set(container, false);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
23
108
|
function textWithoutNestedControls(node) {
|
|
24
109
|
if (node.nodeType === Node.TEXT_NODE)
|
|
25
110
|
return node.textContent ?? '';
|
|
@@ -40,24 +125,27 @@ function browserExtractGeometry() {
|
|
|
40
125
|
return aria.trim() || undefined;
|
|
41
126
|
const labelledBy = el.getAttribute('aria-labelledby');
|
|
42
127
|
if (labelledBy) {
|
|
43
|
-
const
|
|
128
|
+
const resolveChainFrom = (owner, ids, visited) => ids
|
|
44
129
|
.split(/\s+/)
|
|
45
130
|
.map(id => {
|
|
46
131
|
if (visited.has(id))
|
|
47
132
|
return '';
|
|
48
133
|
visited.add(id);
|
|
49
|
-
const target =
|
|
134
|
+
const target = rootScopedElementById(owner, id);
|
|
50
135
|
if (!target)
|
|
51
136
|
return '';
|
|
52
137
|
const chained = target.getAttribute('aria-labelledby');
|
|
53
|
-
if (chained)
|
|
54
|
-
|
|
138
|
+
if (chained) {
|
|
139
|
+
const chainedText = resolveChainFrom(target, chained, visited);
|
|
140
|
+
if (chainedText)
|
|
141
|
+
return chainedText;
|
|
142
|
+
}
|
|
55
143
|
return textWithoutNestedControls(target).replace(/\s+/g, ' ').trim();
|
|
56
144
|
})
|
|
57
145
|
.filter(Boolean)
|
|
58
146
|
.join(' ')
|
|
59
147
|
.trim();
|
|
60
|
-
const text =
|
|
148
|
+
const text = resolveChainFrom(el, labelledBy, new Set());
|
|
61
149
|
if (text)
|
|
62
150
|
return text.length > 100 ? text.slice(0, 100) : text;
|
|
63
151
|
}
|
|
@@ -69,7 +157,10 @@ function browserExtractGeometry() {
|
|
|
69
157
|
return t;
|
|
70
158
|
}
|
|
71
159
|
if (el instanceof HTMLElement && el.id) {
|
|
72
|
-
const
|
|
160
|
+
const root = el.getRootNode();
|
|
161
|
+
const label = (root instanceof Document || root instanceof ShadowRoot)
|
|
162
|
+
? root.querySelector(`label[for="${CSS.escape(el.id)}"]`)
|
|
163
|
+
: document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
73
164
|
const t = label ? textWithoutNestedControls(label).replace(/\s+/g, ' ').trim() : '';
|
|
74
165
|
if (t)
|
|
75
166
|
return t;
|
|
@@ -195,7 +286,7 @@ function browserExtractGeometry() {
|
|
|
195
286
|
return undefined;
|
|
196
287
|
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
|
197
288
|
}
|
|
198
|
-
function referencedText(ids, visited) {
|
|
289
|
+
function referencedText(owner, ids, visited) {
|
|
199
290
|
if (!ids)
|
|
200
291
|
return undefined;
|
|
201
292
|
const seen = visited ?? new Set();
|
|
@@ -205,12 +296,12 @@ function browserExtractGeometry() {
|
|
|
205
296
|
if (seen.has(id))
|
|
206
297
|
return '';
|
|
207
298
|
seen.add(id);
|
|
208
|
-
const target =
|
|
299
|
+
const target = rootScopedElementById(owner, id);
|
|
209
300
|
if (!target)
|
|
210
301
|
return '';
|
|
211
302
|
const chained = target.getAttribute('aria-labelledby');
|
|
212
303
|
if (chained)
|
|
213
|
-
return referencedText(chained, seen) ?? '';
|
|
304
|
+
return referencedText(target, chained, seen) ?? '';
|
|
214
305
|
return target.textContent?.trim() ?? '';
|
|
215
306
|
})
|
|
216
307
|
.filter(Boolean)
|
|
@@ -235,7 +326,7 @@ function browserExtractGeometry() {
|
|
|
235
326
|
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
|
|
236
327
|
try {
|
|
237
328
|
if (el.willValidate)
|
|
238
|
-
return !el.
|
|
329
|
+
return !el.validity.valid;
|
|
239
330
|
}
|
|
240
331
|
catch {
|
|
241
332
|
return false;
|
|
@@ -250,18 +341,18 @@ function browserExtractGeometry() {
|
|
|
250
341
|
return /\b(required|invalid|must|please|enter|select|choose|upload|missing|error)\b/i.test(value);
|
|
251
342
|
}
|
|
252
343
|
function controlErrorText(el) {
|
|
253
|
-
const err = referencedText(el.getAttribute('aria-errormessage'));
|
|
344
|
+
const err = referencedText(el, el.getAttribute('aria-errormessage'));
|
|
254
345
|
if (err)
|
|
255
346
|
return err;
|
|
256
347
|
if (!controlInvalid(el))
|
|
257
348
|
return undefined;
|
|
258
|
-
const described = referencedText(el.getAttribute('aria-describedby'));
|
|
349
|
+
const described = referencedText(el, el.getAttribute('aria-describedby'));
|
|
259
350
|
if (described && looksLikeValidationText(described))
|
|
260
351
|
return described;
|
|
261
352
|
return undefined;
|
|
262
353
|
}
|
|
263
354
|
function controlDescriptionText(el) {
|
|
264
|
-
const described = referencedText(el.getAttribute('aria-describedby'));
|
|
355
|
+
const described = referencedText(el, el.getAttribute('aria-describedby'));
|
|
265
356
|
if (!described)
|
|
266
357
|
return undefined;
|
|
267
358
|
const error = controlErrorText(el);
|
|
@@ -479,12 +570,25 @@ function browserExtractGeometry() {
|
|
|
479
570
|
return true;
|
|
480
571
|
const h = el;
|
|
481
572
|
const style = getComputedStyle(h);
|
|
482
|
-
|
|
573
|
+
const isFileInput = el instanceof HTMLInputElement && el.type === 'file';
|
|
574
|
+
const preservesFileAncestry = isFileInput || subtreeContainsExtractableFileInput(el);
|
|
575
|
+
// Hidden chooser inputs still define required/accept/multiple schema and
|
|
576
|
+
// are commonly activated by a visible dropzone or button. Preserve their
|
|
577
|
+
// semantics even though they have no coordinate target.
|
|
578
|
+
if (!preservesFileAncestry && (style.display === 'none' || style.visibility === 'hidden'))
|
|
483
579
|
return true;
|
|
484
580
|
if (tag === 'input' && el.type === 'hidden')
|
|
485
581
|
return true;
|
|
486
582
|
const rect = h.getBoundingClientRect();
|
|
487
583
|
if (rect.width <= 0 || rect.height <= 0) {
|
|
584
|
+
if (preservesFileAncestry)
|
|
585
|
+
return false;
|
|
586
|
+
// A zero-sized shadow host can still contain positioned, visible
|
|
587
|
+
// controls. The early page instrumentation keeps a weak reference to
|
|
588
|
+
// closed roots so read-only extraction can traverse them without
|
|
589
|
+
// adding observable attributes to the application DOM.
|
|
590
|
+
if (registeredShadowRootForHost(el))
|
|
591
|
+
return false;
|
|
488
592
|
// Don't skip elements that carry an explicit interactive role or are
|
|
489
593
|
// form-control elements. react-select v5 (and similar custom-combobox
|
|
490
594
|
// libraries) renders the trigger as an `<input role="combobox">` with
|
|
@@ -516,7 +620,7 @@ function browserExtractGeometry() {
|
|
|
516
620
|
if (!shouldSkip(c))
|
|
517
621
|
out.push(c);
|
|
518
622
|
}
|
|
519
|
-
const sr = container
|
|
623
|
+
const sr = registeredShadowRootForHost(container);
|
|
520
624
|
if (sr) {
|
|
521
625
|
for (const c of sr.children) {
|
|
522
626
|
if (!shouldSkip(c))
|
|
@@ -688,7 +792,18 @@ function browserExtractGeometry() {
|
|
|
688
792
|
const role = defaultRoleForTag(el, tag);
|
|
689
793
|
if (role)
|
|
690
794
|
semantic.role = role;
|
|
691
|
-
|
|
795
|
+
const closedShadowRoot = closedShadowRootForElement(el);
|
|
796
|
+
if (closedShadowRoot) {
|
|
797
|
+
// Closed-root descendants cannot be resolved by Playwright locators.
|
|
798
|
+
// Keep non-form interactive geometry available for coordinate actions,
|
|
799
|
+
// but never advertise an authored semantic target that actions cannot
|
|
800
|
+
// subsequently pierce.
|
|
801
|
+
semantic.shadowMode = 'closed';
|
|
802
|
+
semantic.coordinateOnly = true;
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
addAuthoredControlIdentity(semantic, el, tag, role);
|
|
806
|
+
}
|
|
692
807
|
if (el instanceof HTMLSelectElement)
|
|
693
808
|
semantic.options = nativeSelectOptions(el);
|
|
694
809
|
const al = el.getAttribute('aria-label');
|
|
@@ -741,6 +856,17 @@ function browserExtractGeometry() {
|
|
|
741
856
|
semantic.ariaDisabled = true;
|
|
742
857
|
if (h instanceof HTMLTextAreaElement && h.disabled)
|
|
743
858
|
semantic.ariaDisabled = true;
|
|
859
|
+
const authoredDisabled = el.getAttribute('aria-disabled');
|
|
860
|
+
if (authoredDisabled !== null)
|
|
861
|
+
semantic.ariaDisabled = authoredDisabled === 'true';
|
|
862
|
+
if ((h instanceof HTMLInputElement || h instanceof HTMLTextAreaElement) &&
|
|
863
|
+
h.readOnly)
|
|
864
|
+
semantic.ariaReadonly = true;
|
|
865
|
+
const authoredReadonly = el.getAttribute('aria-readonly');
|
|
866
|
+
if (authoredReadonly !== null)
|
|
867
|
+
semantic.ariaReadonly = authoredReadonly === 'true';
|
|
868
|
+
if (h.inert || el.hasAttribute('inert'))
|
|
869
|
+
semantic.inert = true;
|
|
744
870
|
const exp = el.getAttribute('aria-expanded');
|
|
745
871
|
if (exp !== null)
|
|
746
872
|
semantic.ariaExpanded = exp === 'true';
|
|
@@ -781,6 +907,16 @@ function browserExtractGeometry() {
|
|
|
781
907
|
if (h instanceof HTMLInputElement && h.type === 'file') {
|
|
782
908
|
sem.role = 'button';
|
|
783
909
|
sem.fileInput = true;
|
|
910
|
+
sem.inputType = 'file';
|
|
911
|
+
const accept = h.accept.trim();
|
|
912
|
+
if (accept)
|
|
913
|
+
sem.accept = accept;
|
|
914
|
+
sem.multiple = h.multiple;
|
|
915
|
+
if (!name) {
|
|
916
|
+
const authoredFallback = h.getAttribute('name')?.trim() || h.getAttribute('id')?.trim();
|
|
917
|
+
if (authoredFallback)
|
|
918
|
+
sem.ariaLabel = authoredFallback;
|
|
919
|
+
}
|
|
784
920
|
}
|
|
785
921
|
if (name && !sem.ariaLabel)
|
|
786
922
|
sem.ariaLabel = name;
|
|
@@ -812,6 +948,11 @@ function browserExtractGeometry() {
|
|
|
812
948
|
function extractElement(el) {
|
|
813
949
|
if (shouldSkip(el))
|
|
814
950
|
return null;
|
|
951
|
+
// Actions cannot pierce a closed shadow root. Omitting its form controls
|
|
952
|
+
// keeps form schemas truthful; ordinary interactive content (for example,
|
|
953
|
+
// a button) remains available as explicitly coordinate-only geometry.
|
|
954
|
+
if (isClosedShadowFormControl(el))
|
|
955
|
+
return null;
|
|
815
956
|
const tag = el.tagName.toLowerCase();
|
|
816
957
|
if (tag === 'img' && el instanceof HTMLImageElement)
|
|
817
958
|
return extractImage(el);
|
|
@@ -828,8 +969,14 @@ function browserExtractGeometry() {
|
|
|
828
969
|
children: [],
|
|
829
970
|
};
|
|
830
971
|
const sem = semanticFor(iframe, 'iframe');
|
|
972
|
+
const name = getAccessibleName(iframe);
|
|
973
|
+
if (name && !sem.ariaLabel)
|
|
974
|
+
sem.ariaLabel = name;
|
|
831
975
|
sem.iframe = true;
|
|
832
976
|
sem.iframePlaceholder = true;
|
|
977
|
+
const iframeIdentity = iframeIdentityForElement(iframe);
|
|
978
|
+
if (iframeIdentity)
|
|
979
|
+
sem.iframeIdentity = iframeIdentity;
|
|
833
980
|
return {
|
|
834
981
|
layout,
|
|
835
982
|
tree: {
|
|
@@ -905,7 +1052,12 @@ function browserExtractGeometry() {
|
|
|
905
1052
|
const body = document.body;
|
|
906
1053
|
if (!body) {
|
|
907
1054
|
const emptyLayout = { x: 0, y: 0, width: 0, height: 0, children: [] };
|
|
908
|
-
const emptyTree = {
|
|
1055
|
+
const emptyTree = {
|
|
1056
|
+
kind: 'box',
|
|
1057
|
+
props: {},
|
|
1058
|
+
semantic: { tag: 'body' },
|
|
1059
|
+
children: [],
|
|
1060
|
+
};
|
|
909
1061
|
return { layout: emptyLayout, tree: emptyTree };
|
|
910
1062
|
}
|
|
911
1063
|
const elementChildren = displayedElementChildren(body);
|
|
@@ -944,13 +1096,639 @@ function cloneLayout(layout) {
|
|
|
944
1096
|
function cloneTree(tree) {
|
|
945
1097
|
return structuredClone(tree);
|
|
946
1098
|
}
|
|
947
|
-
|
|
1099
|
+
const axDocumentStateByPage = new WeakMap();
|
|
1100
|
+
const defaultAxSessionManagerByPage = new WeakMap();
|
|
1101
|
+
const AX_HEURISTIC_MIN_INTERVAL_MS = 10_000;
|
|
1102
|
+
function axSessionManagerForPage(page, explicit) {
|
|
1103
|
+
if (explicit)
|
|
1104
|
+
return explicit;
|
|
1105
|
+
const existing = defaultAxSessionManagerByPage.get(page);
|
|
1106
|
+
if (existing)
|
|
1107
|
+
return existing;
|
|
1108
|
+
const manager = createCdpAxSessionManager();
|
|
1109
|
+
defaultAxSessionManagerByPage.set(page, manager);
|
|
1110
|
+
page.once('close', () => {
|
|
1111
|
+
if (defaultAxSessionManagerByPage.get(page) === manager) {
|
|
1112
|
+
defaultAxSessionManagerByPage.delete(page);
|
|
1113
|
+
}
|
|
1114
|
+
void manager.close();
|
|
1115
|
+
});
|
|
1116
|
+
return manager;
|
|
1117
|
+
}
|
|
1118
|
+
function snapshotGeometryFingerprint(snapshot) {
|
|
1119
|
+
const parts = [];
|
|
1120
|
+
const rootSemantic = snapshot.tree.semantic ?? {};
|
|
1121
|
+
parts.push(`scroll:${String(rootSemantic.scrollX ?? '')},${String(rootSemantic.scrollY ?? '')}`);
|
|
1122
|
+
function visit(tree, layout) {
|
|
1123
|
+
const semantic = tree.semantic ?? {};
|
|
1124
|
+
parts.push([
|
|
1125
|
+
typeof semantic.tag === 'string' ? semantic.tag : '',
|
|
1126
|
+
typeof semantic.role === 'string' ? semantic.role : '',
|
|
1127
|
+
layout.x,
|
|
1128
|
+
layout.y,
|
|
1129
|
+
layout.width,
|
|
1130
|
+
layout.height,
|
|
1131
|
+
tree.children?.length ?? 0,
|
|
1132
|
+
].join(':'));
|
|
1133
|
+
const children = tree.children ?? [];
|
|
1134
|
+
for (let index = 0; index < children.length; index++) {
|
|
1135
|
+
const childLayout = layout.children[index];
|
|
1136
|
+
if (childLayout)
|
|
1137
|
+
visit(children[index], childLayout);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
visit(snapshot.tree, snapshot.layout);
|
|
1141
|
+
return parts.join('|');
|
|
1142
|
+
}
|
|
1143
|
+
function snapshotSemanticFingerprint(snapshot) {
|
|
1144
|
+
// The un-enriched DOM tree is already deterministic and contains the
|
|
1145
|
+
// action-relevant text, accessible labels, values, checked/selected state,
|
|
1146
|
+
// disabled/readonly state, options, handlers, and authored identities. Keep
|
|
1147
|
+
// the exact serialization instead of a lossy hash: a cache miss is cheap,
|
|
1148
|
+
// while a collision could resurrect a stale destructive action label.
|
|
1149
|
+
return JSON.stringify(snapshot.tree);
|
|
1150
|
+
}
|
|
1151
|
+
function axHeuristicFingerprint(snapshot) {
|
|
1152
|
+
// This is only computed when the cheap DOM heuristic already says AX is
|
|
1153
|
+
// needed. Remembering the last unhealthy shape prevents a permanently
|
|
1154
|
+
// unnamed control from launching getFullAXTree on every RAF extraction.
|
|
1155
|
+
const parts = [];
|
|
1156
|
+
let meaningfulCount = 0;
|
|
1157
|
+
function visit(tree, layout, path) {
|
|
1158
|
+
const semantic = tree.semantic ?? {};
|
|
1159
|
+
const role = typeof semantic.role === 'string' ? semantic.role : '';
|
|
1160
|
+
const label = axNodeLabel(tree);
|
|
1161
|
+
const interactive = !!tree.handlers?.onClick || !!tree.handlers?.onKeyDown || !!tree.handlers?.onKeyUp ||
|
|
1162
|
+
['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab'].includes(role);
|
|
1163
|
+
if (tree.handlers || tree.props.text || (role && role !== 'group'))
|
|
1164
|
+
meaningfulCount += 1;
|
|
1165
|
+
if (interactive && !label) {
|
|
1166
|
+
parts.push(`${path}:${role}:${layout.x},${layout.y},${layout.width},${layout.height}`);
|
|
1167
|
+
}
|
|
1168
|
+
const children = tree.children ?? [];
|
|
1169
|
+
for (let index = 0; index < children.length; index++) {
|
|
1170
|
+
const childLayout = layout.children[index];
|
|
1171
|
+
if (childLayout)
|
|
1172
|
+
visit(children[index], childLayout, `${path}.${index}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
visit(snapshot.tree, snapshot.layout, '0');
|
|
1176
|
+
return `${Math.min(meaningfulCount, 2)}|${parts.join('|')}`;
|
|
1177
|
+
}
|
|
1178
|
+
function axCacheHasPayload(state) {
|
|
1179
|
+
return state.cachedAppendNodes.length > 0 || state.cachedSemanticPatches.length > 0;
|
|
1180
|
+
}
|
|
1181
|
+
function clearAxCache(state) {
|
|
1182
|
+
state.cachedAppendNodes = [];
|
|
1183
|
+
state.cachedSemanticPatches = [];
|
|
1184
|
+
state.cachedGeometryFingerprint = undefined;
|
|
1185
|
+
state.cachedSemanticFingerprint = undefined;
|
|
1186
|
+
state.cachedAxRevision = undefined;
|
|
1187
|
+
}
|
|
1188
|
+
function planAxProbe(page, documentIdentity, snapshot, axRevision) {
|
|
1189
|
+
const heuristicRequestsAx = shouldEnrichSnapshotWithCdpAx(snapshot);
|
|
1190
|
+
const geometryFingerprint = snapshotGeometryFingerprint(snapshot);
|
|
1191
|
+
const semanticFingerprint = snapshotSemanticFingerprint(snapshot);
|
|
1192
|
+
if (!documentIdentity) {
|
|
1193
|
+
return {
|
|
1194
|
+
firstDocumentProbe: false,
|
|
1195
|
+
shouldRun: heuristicRequestsAx,
|
|
1196
|
+
mayReplayCache: false,
|
|
1197
|
+
geometryFingerprint,
|
|
1198
|
+
semanticFingerprint,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
const heuristicFingerprint = heuristicRequestsAx ? axHeuristicFingerprint(snapshot) : undefined;
|
|
1202
|
+
const now = performance.now();
|
|
1203
|
+
let state = axDocumentStateByPage.get(page);
|
|
1204
|
+
if (!state || state.documentIdentity !== documentIdentity) {
|
|
1205
|
+
state = {
|
|
1206
|
+
documentIdentity,
|
|
1207
|
+
probeStatus: 'pending',
|
|
1208
|
+
lastHeuristicFingerprint: heuristicFingerprint,
|
|
1209
|
+
lastAxProbeAtMs: now,
|
|
1210
|
+
currentGeometryFingerprint: geometryFingerprint,
|
|
1211
|
+
currentSemanticFingerprint: semanticFingerprint,
|
|
1212
|
+
cachedAppendNodes: [],
|
|
1213
|
+
cachedSemanticPatches: [],
|
|
1214
|
+
cacheInvalidated: false,
|
|
1215
|
+
refreshingWithSafeCache: false,
|
|
1216
|
+
automaticRetryUsed: false,
|
|
1217
|
+
};
|
|
1218
|
+
// Claim synchronously before any CDP await so concurrent RAF extracts
|
|
1219
|
+
// cannot launch duplicate full-tree traversals for this document.
|
|
1220
|
+
axDocumentStateByPage.set(page, state);
|
|
1221
|
+
return {
|
|
1222
|
+
firstDocumentProbe: true,
|
|
1223
|
+
shouldRun: true,
|
|
1224
|
+
mayReplayCache: false,
|
|
1225
|
+
geometryFingerprint,
|
|
1226
|
+
semanticFingerprint,
|
|
1227
|
+
state,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
// Record the newest DOM snapshot synchronously. A background probe may
|
|
1231
|
+
// finish after a newer extraction; it is allowed to populate cache only if
|
|
1232
|
+
// both the semantic and geometry revisions still match its starting point.
|
|
1233
|
+
state.currentGeometryFingerprint = geometryFingerprint;
|
|
1234
|
+
state.currentSemanticFingerprint = semanticFingerprint;
|
|
1235
|
+
const elapsedSinceProbe = Math.max(0, now - state.lastAxProbeAtMs);
|
|
1236
|
+
const retryAfterMs = Math.max(0, AX_HEURISTIC_MIN_INTERVAL_MS - elapsedSinceProbe);
|
|
1237
|
+
const unclaimedRetryAfterMs = state.automaticRetryUsed ? undefined : retryAfterMs;
|
|
1238
|
+
if (state.probeStatus === 'pending') {
|
|
1239
|
+
const wasRefreshingWithSafeCache = state.refreshingWithSafeCache;
|
|
1240
|
+
const refreshingCacheStillSafe = wasRefreshingWithSafeCache &&
|
|
1241
|
+
state.cachedGeometryFingerprint === geometryFingerprint &&
|
|
1242
|
+
state.cachedSemanticFingerprint === semanticFingerprint &&
|
|
1243
|
+
!!axRevision &&
|
|
1244
|
+
state.cachedAxRevision === axRevision;
|
|
1245
|
+
if (state.refreshingWithSafeCache && !refreshingCacheStillSafe) {
|
|
1246
|
+
clearAxCache(state);
|
|
1247
|
+
state.cacheInvalidated = true;
|
|
1248
|
+
state.refreshingWithSafeCache = false;
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
firstDocumentProbe: false,
|
|
1252
|
+
shouldRun: false,
|
|
1253
|
+
mayReplayCache: refreshingCacheStillSafe && axCacheHasPayload(state),
|
|
1254
|
+
geometryFingerprint,
|
|
1255
|
+
semanticFingerprint,
|
|
1256
|
+
...(wasRefreshingWithSafeCache && !refreshingCacheStillSafe
|
|
1257
|
+
? { cacheReplaySuppressed: true }
|
|
1258
|
+
: {}),
|
|
1259
|
+
state,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
if (state.probeStatus === 'failed') {
|
|
1263
|
+
if (elapsedSinceProbe < AX_HEURISTIC_MIN_INTERVAL_MS) {
|
|
1264
|
+
return {
|
|
1265
|
+
firstDocumentProbe: false,
|
|
1266
|
+
shouldRun: false,
|
|
1267
|
+
mayReplayCache: false,
|
|
1268
|
+
geometryFingerprint,
|
|
1269
|
+
semanticFingerprint,
|
|
1270
|
+
retryAfterMs: unclaimedRetryAfterMs,
|
|
1271
|
+
state,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
state.probeStatus = 'pending';
|
|
1275
|
+
state.lastAxProbeAtMs = now;
|
|
1276
|
+
state.refreshingWithSafeCache = false;
|
|
1277
|
+
return {
|
|
1278
|
+
firstDocumentProbe: false,
|
|
1279
|
+
shouldRun: true,
|
|
1280
|
+
mayReplayCache: false,
|
|
1281
|
+
geometryFingerprint,
|
|
1282
|
+
semanticFingerprint,
|
|
1283
|
+
state,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
const cachedSnapshotInvalid = state.cacheInvalidated ||
|
|
1287
|
+
state.cachedGeometryFingerprint !== geometryFingerprint ||
|
|
1288
|
+
state.cachedSemanticFingerprint !== semanticFingerprint ||
|
|
1289
|
+
!axRevision ||
|
|
1290
|
+
state.cachedAxRevision !== axRevision;
|
|
1291
|
+
if (cachedSnapshotInvalid) {
|
|
1292
|
+
// Drop the stale payload immediately. Retaining it until the cadence opens
|
|
1293
|
+
// risks replay if a same-bounds SPA replacement later happens to restore a
|
|
1294
|
+
// previous geometry shape.
|
|
1295
|
+
clearAxCache(state);
|
|
1296
|
+
state.cacheInvalidated = true;
|
|
1297
|
+
state.refreshingWithSafeCache = false;
|
|
1298
|
+
if (elapsedSinceProbe >= AX_HEURISTIC_MIN_INTERVAL_MS) {
|
|
1299
|
+
state.probeStatus = 'pending';
|
|
1300
|
+
state.lastAxProbeAtMs = now;
|
|
1301
|
+
state.lastHeuristicFingerprint = heuristicFingerprint;
|
|
1302
|
+
return {
|
|
1303
|
+
firstDocumentProbe: false,
|
|
1304
|
+
shouldRun: true,
|
|
1305
|
+
mayReplayCache: false,
|
|
1306
|
+
geometryFingerprint,
|
|
1307
|
+
semanticFingerprint,
|
|
1308
|
+
cacheReplaySuppressed: true,
|
|
1309
|
+
state,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
return {
|
|
1313
|
+
firstDocumentProbe: false,
|
|
1314
|
+
shouldRun: false,
|
|
1315
|
+
mayReplayCache: false,
|
|
1316
|
+
geometryFingerprint,
|
|
1317
|
+
semanticFingerprint,
|
|
1318
|
+
cacheReplaySuppressed: true,
|
|
1319
|
+
retryAfterMs: unclaimedRetryAfterMs,
|
|
1320
|
+
state,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
if (elapsedSinceProbe >= AX_HEURISTIC_MIN_INTERVAL_MS) {
|
|
1324
|
+
const mayReplayCache = axCacheHasPayload(state);
|
|
1325
|
+
state.probeStatus = 'pending';
|
|
1326
|
+
state.lastAxProbeAtMs = now;
|
|
1327
|
+
state.lastHeuristicFingerprint = heuristicFingerprint;
|
|
1328
|
+
state.refreshingWithSafeCache = mayReplayCache;
|
|
1329
|
+
return {
|
|
1330
|
+
firstDocumentProbe: false,
|
|
1331
|
+
shouldRun: true,
|
|
1332
|
+
mayReplayCache,
|
|
1333
|
+
geometryFingerprint,
|
|
1334
|
+
semanticFingerprint,
|
|
1335
|
+
state,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
if (!heuristicRequestsAx) {
|
|
1339
|
+
state.lastHeuristicFingerprint = undefined;
|
|
1340
|
+
return {
|
|
1341
|
+
firstDocumentProbe: false,
|
|
1342
|
+
shouldRun: false,
|
|
1343
|
+
mayReplayCache: true,
|
|
1344
|
+
geometryFingerprint,
|
|
1345
|
+
semanticFingerprint,
|
|
1346
|
+
state,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
if (state.lastHeuristicFingerprint === heuristicFingerprint) {
|
|
1350
|
+
return {
|
|
1351
|
+
firstDocumentProbe: false,
|
|
1352
|
+
shouldRun: false,
|
|
1353
|
+
mayReplayCache: true,
|
|
1354
|
+
geometryFingerprint,
|
|
1355
|
+
semanticFingerprint,
|
|
1356
|
+
state,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
if (elapsedSinceProbe < AX_HEURISTIC_MIN_INTERVAL_MS) {
|
|
1360
|
+
// Keep the prior fingerprint so the changed unhealthy shape remains
|
|
1361
|
+
// pending and is probed once the bounded cadence opens.
|
|
1362
|
+
return {
|
|
1363
|
+
firstDocumentProbe: false,
|
|
1364
|
+
shouldRun: false,
|
|
1365
|
+
mayReplayCache: true,
|
|
1366
|
+
geometryFingerprint,
|
|
1367
|
+
semanticFingerprint,
|
|
1368
|
+
retryAfterMs: unclaimedRetryAfterMs,
|
|
1369
|
+
state,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
state.lastHeuristicFingerprint = heuristicFingerprint;
|
|
1373
|
+
state.probeStatus = 'pending';
|
|
1374
|
+
state.lastAxProbeAtMs = now;
|
|
1375
|
+
state.refreshingWithSafeCache = axCacheHasPayload(state);
|
|
1376
|
+
return {
|
|
1377
|
+
firstDocumentProbe: false,
|
|
1378
|
+
shouldRun: true,
|
|
1379
|
+
mayReplayCache: state.refreshingWithSafeCache,
|
|
1380
|
+
geometryFingerprint,
|
|
1381
|
+
semanticFingerprint,
|
|
1382
|
+
state,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
function axNodeLabel(tree) {
|
|
1386
|
+
const semantic = tree.semantic ?? {};
|
|
1387
|
+
const label = typeof semantic.ariaLabel === 'string' ? semantic.ariaLabel : tree.props.text;
|
|
1388
|
+
return typeof label === 'string' ? label.replace(/\s+/g, ' ').trim().toLowerCase() : '';
|
|
1389
|
+
}
|
|
1390
|
+
function axNodeRole(tree) {
|
|
1391
|
+
const semantic = tree.semantic ?? {};
|
|
1392
|
+
if (typeof semantic.role === 'string')
|
|
1393
|
+
return semantic.role;
|
|
1394
|
+
return typeof semantic.a11yRoleHint === 'string' ? semantic.a11yRoleHint : '';
|
|
1395
|
+
}
|
|
1396
|
+
function layoutsRepresentSameAxNode(a, b) {
|
|
1397
|
+
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.width - b.width), Math.abs(a.height - b.height)) <= 3;
|
|
1398
|
+
}
|
|
1399
|
+
function snapshotContainsCachedAxNode(tree, layout, cached) {
|
|
1400
|
+
if (axNodeRole(tree) === axNodeRole(cached.tree) &&
|
|
1401
|
+
axNodeLabel(tree) === axNodeLabel(cached.tree) &&
|
|
1402
|
+
layoutsRepresentSameAxNode(layout, cached.layout))
|
|
1403
|
+
return true;
|
|
1404
|
+
const children = tree.children ?? [];
|
|
1405
|
+
for (let index = 0; index < children.length; index++) {
|
|
1406
|
+
const childLayout = layout.children[index];
|
|
1407
|
+
if (childLayout && snapshotContainsCachedAxNode(children[index], childLayout, cached))
|
|
1408
|
+
return true;
|
|
1409
|
+
}
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
function cacheAxAppendNodes(snapshot, baselineTree, state, geometryFingerprint, semanticFingerprint, axRevision) {
|
|
1413
|
+
const cached = [];
|
|
1414
|
+
const children = snapshot.tree.children ?? [];
|
|
1415
|
+
for (let index = 0; index < children.length; index++) {
|
|
1416
|
+
const tree = children[index];
|
|
1417
|
+
const layout = snapshot.layout.children[index];
|
|
1418
|
+
const cacheableAxFallback = tree.semantic?.coordinateOnly === true &&
|
|
1419
|
+
(tree.semantic?.a11yAppended === true || tree.semantic?.a11yFallback === true);
|
|
1420
|
+
if (!layout || !cacheableAxFallback)
|
|
1421
|
+
continue;
|
|
1422
|
+
cached.push({ tree: cloneTree(tree), layout: cloneLayout(layout) });
|
|
1423
|
+
}
|
|
1424
|
+
const semanticPatches = [];
|
|
1425
|
+
const patchKeys = ['ariaLabel', 'a11yRoleHint', 'a11yEnriched'];
|
|
1426
|
+
const collectSemanticPatches = (before, after, path) => {
|
|
1427
|
+
const beforeSemantic = before.semantic ?? {};
|
|
1428
|
+
const afterSemantic = after.semantic ?? {};
|
|
1429
|
+
const semantic = {};
|
|
1430
|
+
for (const key of patchKeys) {
|
|
1431
|
+
if (afterSemantic[key] !== beforeSemantic[key] && afterSemantic[key] !== undefined) {
|
|
1432
|
+
semantic[key] = structuredClone(afterSemantic[key]);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (Object.keys(semantic).length > 0)
|
|
1436
|
+
semanticPatches.push({ path: [...path], semantic });
|
|
1437
|
+
const beforeChildren = before.children ?? [];
|
|
1438
|
+
const afterChildren = after.children ?? [];
|
|
1439
|
+
for (let index = 0; index < beforeChildren.length; index++) {
|
|
1440
|
+
const afterChild = afterChildren[index];
|
|
1441
|
+
if (afterChild)
|
|
1442
|
+
collectSemanticPatches(beforeChildren[index], afterChild, [...path, index]);
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
collectSemanticPatches(baselineTree, snapshot.tree, []);
|
|
1446
|
+
state.cachedAppendNodes = cached;
|
|
1447
|
+
state.cachedSemanticPatches = semanticPatches;
|
|
1448
|
+
state.cachedGeometryFingerprint = geometryFingerprint;
|
|
1449
|
+
state.cachedSemanticFingerprint = semanticFingerprint;
|
|
1450
|
+
state.cachedAxRevision = axRevision;
|
|
1451
|
+
state.cacheInvalidated = false;
|
|
1452
|
+
}
|
|
1453
|
+
function replayCachedAxSemanticPatches(snapshot, state) {
|
|
1454
|
+
let replayed = 0;
|
|
1455
|
+
for (const patch of state.cachedSemanticPatches) {
|
|
1456
|
+
let target = snapshot.tree;
|
|
1457
|
+
for (const index of patch.path) {
|
|
1458
|
+
target = target.children?.[index];
|
|
1459
|
+
if (!target)
|
|
1460
|
+
break;
|
|
1461
|
+
}
|
|
1462
|
+
if (!target)
|
|
1463
|
+
continue;
|
|
1464
|
+
target.semantic = {
|
|
1465
|
+
...(target.semantic ?? {}),
|
|
1466
|
+
...structuredClone(patch.semantic),
|
|
1467
|
+
a11yReplayed: true,
|
|
1468
|
+
};
|
|
1469
|
+
replayed += 1;
|
|
1470
|
+
}
|
|
1471
|
+
if (replayed > 0) {
|
|
1472
|
+
snapshot.tree.semantic = {
|
|
1473
|
+
...(snapshot.tree.semantic ?? {}),
|
|
1474
|
+
a11yPatchReplayed: true,
|
|
1475
|
+
a11yPatchedCount: replayed,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
return replayed;
|
|
1479
|
+
}
|
|
1480
|
+
function replayCachedAxAppendNodes(snapshot, state) {
|
|
1481
|
+
let replayed = 0;
|
|
1482
|
+
let replayedFallback = false;
|
|
1483
|
+
if (!snapshot.tree.children)
|
|
1484
|
+
snapshot.tree.children = [];
|
|
1485
|
+
for (const cached of state.cachedAppendNodes) {
|
|
1486
|
+
if (snapshotContainsCachedAxNode(snapshot.tree, snapshot.layout, cached))
|
|
1487
|
+
continue;
|
|
1488
|
+
const tree = cloneTree(cached.tree);
|
|
1489
|
+
tree.semantic = { ...(tree.semantic ?? {}), a11yReplayed: true };
|
|
1490
|
+
if (tree.semantic.a11yFallback === true)
|
|
1491
|
+
replayedFallback = true;
|
|
1492
|
+
snapshot.tree.children.push(tree);
|
|
1493
|
+
snapshot.layout.children.push(cloneLayout(cached.layout));
|
|
1494
|
+
replayed += 1;
|
|
1495
|
+
}
|
|
1496
|
+
if (replayed > 0) {
|
|
1497
|
+
snapshot.tree.semantic = {
|
|
1498
|
+
...(snapshot.tree.semantic ?? {}),
|
|
1499
|
+
a11yAppendUsed: true,
|
|
1500
|
+
a11yAppendReplayed: true,
|
|
1501
|
+
a11yAppendedCount: replayed,
|
|
1502
|
+
...(replayedFallback ? { a11yFallbackUsed: true } : {}),
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
return replayed;
|
|
1506
|
+
}
|
|
1507
|
+
function axCachePayloadSignature(state) {
|
|
1508
|
+
return JSON.stringify({
|
|
1509
|
+
appendNodes: state.cachedAppendNodes,
|
|
1510
|
+
semanticPatches: state.cachedSemanticPatches,
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
async function runAxProbeAndUpdateState(page, snapshot, state, geometryFingerprint, semanticFingerprint, axSessionManager) {
|
|
1514
|
+
const baselineTree = cloneTree(snapshot.tree);
|
|
1515
|
+
const priorCacheSignature = state ? axCachePayloadSignature(state) : undefined;
|
|
1516
|
+
let axRevisionAtStart;
|
|
1517
|
+
try {
|
|
1518
|
+
await axSessionManager?.get(page);
|
|
1519
|
+
axRevisionAtStart = axSessionManager?.revisionToken();
|
|
1520
|
+
}
|
|
1521
|
+
catch {
|
|
1522
|
+
// The fail-soft enrichment call below owns reset/retry behavior. An
|
|
1523
|
+
// unclaimed starting revision can never produce a replayable cache.
|
|
1524
|
+
}
|
|
1525
|
+
const succeeded = await enrichSnapshotWithCdpAx(page, snapshot, axSessionManager);
|
|
1526
|
+
if (!state || axDocumentStateByPage.get(page) !== state) {
|
|
1527
|
+
return { succeeded, cacheReady: false };
|
|
1528
|
+
}
|
|
1529
|
+
state.lastAxProbeAtMs = performance.now();
|
|
1530
|
+
state.refreshingWithSafeCache = false;
|
|
1531
|
+
const axRevisionAtEnd = axSessionManager?.revisionToken();
|
|
1532
|
+
const unclaimedRetryAfterMs = state.automaticRetryUsed
|
|
1533
|
+
? undefined
|
|
1534
|
+
: AX_HEURISTIC_MIN_INTERVAL_MS;
|
|
1535
|
+
if (!succeeded) {
|
|
1536
|
+
state.probeStatus = 'failed';
|
|
1537
|
+
state.cacheInvalidated = true;
|
|
1538
|
+
clearAxCache(state);
|
|
1539
|
+
return {
|
|
1540
|
+
succeeded: false,
|
|
1541
|
+
cacheReady: false,
|
|
1542
|
+
retryAfterMs: unclaimedRetryAfterMs,
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
if (state.currentGeometryFingerprint !== geometryFingerprint ||
|
|
1546
|
+
state.currentSemanticFingerprint !== semanticFingerprint ||
|
|
1547
|
+
!axRevisionAtStart ||
|
|
1548
|
+
axRevisionAtStart !== axRevisionAtEnd) {
|
|
1549
|
+
// A newer extraction observed a different DOM while this CDP request was
|
|
1550
|
+
// in flight. The AX response is valid for its source snapshot but unsafe
|
|
1551
|
+
// to replay into the current document. Retry only after the normal bound.
|
|
1552
|
+
state.probeStatus = 'failed';
|
|
1553
|
+
state.cacheInvalidated = true;
|
|
1554
|
+
clearAxCache(state);
|
|
1555
|
+
return {
|
|
1556
|
+
succeeded: true,
|
|
1557
|
+
cacheReady: false,
|
|
1558
|
+
retryAfterMs: unclaimedRetryAfterMs,
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
state.probeStatus = 'succeeded';
|
|
1562
|
+
state.automaticRetryUsed = false;
|
|
1563
|
+
cacheAxAppendNodes(snapshot, baselineTree, state, geometryFingerprint, semanticFingerprint, axRevisionAtEnd);
|
|
1564
|
+
const nextCacheSignature = axCachePayloadSignature(state);
|
|
1565
|
+
return {
|
|
1566
|
+
succeeded: true,
|
|
1567
|
+
cacheReady: priorCacheSignature !== nextCacheSignature,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
const hostDocumentIdentityByFrame = new WeakMap();
|
|
1571
|
+
/**
|
|
1572
|
+
* Track the actual remote Document object in Playwright's host process.
|
|
1573
|
+
*
|
|
1574
|
+
* A token stored on `document` is not an identity boundary: application code
|
|
1575
|
+
* can preseed or copy any page-readable property across navigations. Remote
|
|
1576
|
+
* object equality is instead checked with a host-held handle whose opaque
|
|
1577
|
+
* identity never enters the page snapshot.
|
|
1578
|
+
*/
|
|
1579
|
+
async function currentHostDocumentIdentity(frame) {
|
|
1580
|
+
const candidate = await frame.evaluateHandle(() => document);
|
|
1581
|
+
const previous = hostDocumentIdentityByFrame.get(frame);
|
|
1582
|
+
if (previous) {
|
|
1583
|
+
try {
|
|
1584
|
+
const unchanged = await candidate.evaluate((current, prior) => current === prior, previous.handle);
|
|
1585
|
+
if (unchanged) {
|
|
1586
|
+
await candidate.dispose();
|
|
1587
|
+
return previous.identity;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
catch {
|
|
1591
|
+
// Handles from a destroyed execution context cannot be compared with
|
|
1592
|
+
// the replacement document. That is a document transition, not a reason
|
|
1593
|
+
// to reuse the prior cache identity.
|
|
1594
|
+
}
|
|
1595
|
+
await previous.handle.dispose().catch(() => { });
|
|
1596
|
+
}
|
|
1597
|
+
const next = { handle: candidate, identity: randomUUID() };
|
|
1598
|
+
hostDocumentIdentityByFrame.set(frame, next);
|
|
1599
|
+
return next.identity;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Associate each Playwright child frame with its exact owner element without
|
|
1603
|
+
* mutating authored DOM attributes. The WeakMap identity lives in the page's
|
|
1604
|
+
* main world so the browser-side snapshot can put the same opaque identity on
|
|
1605
|
+
* the matching iframe placeholder. Entries disappear with their elements.
|
|
1606
|
+
*/
|
|
1607
|
+
async function identifyChildFrames(ownerFrame) {
|
|
1608
|
+
const framesByIdentity = new Map();
|
|
1609
|
+
const ambiguousIdentities = new Set();
|
|
1610
|
+
for (const childFrame of ownerFrame.childFrames()) {
|
|
1611
|
+
if (childFrame.isDetached())
|
|
1612
|
+
continue;
|
|
1613
|
+
let handle;
|
|
1614
|
+
try {
|
|
1615
|
+
handle = await childFrame.frameElement();
|
|
1616
|
+
const identity = await handle.evaluate((iframe, candidate) => {
|
|
1617
|
+
if (!(iframe instanceof HTMLIFrameElement) || !iframe.isConnected)
|
|
1618
|
+
return null;
|
|
1619
|
+
const key = Symbol.for('geometra.iframeIdentities');
|
|
1620
|
+
const globals = globalThis;
|
|
1621
|
+
let registry = globals[key];
|
|
1622
|
+
if (!(registry instanceof WeakMap)) {
|
|
1623
|
+
registry = new WeakMap();
|
|
1624
|
+
try {
|
|
1625
|
+
Object.defineProperty(globalThis, key, {
|
|
1626
|
+
configurable: true,
|
|
1627
|
+
value: registry,
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const identities = registry;
|
|
1635
|
+
const existing = identities.get(iframe);
|
|
1636
|
+
if (typeof existing === 'string' && existing)
|
|
1637
|
+
return existing;
|
|
1638
|
+
identities.set(iframe, candidate);
|
|
1639
|
+
return candidate;
|
|
1640
|
+
}, `iframe-${crypto.randomUUID()}`);
|
|
1641
|
+
if (identity &&
|
|
1642
|
+
!childFrame.isDetached() &&
|
|
1643
|
+
childFrame.parentFrame() === ownerFrame) {
|
|
1644
|
+
if (framesByIdentity.has(identity)) {
|
|
1645
|
+
// A corrupted/page-supplied registry must not be able to alias two
|
|
1646
|
+
// owners. Remove both candidates and leave their placeholders empty.
|
|
1647
|
+
framesByIdentity.delete(identity);
|
|
1648
|
+
ambiguousIdentities.add(identity);
|
|
1649
|
+
}
|
|
1650
|
+
else if (!ambiguousIdentities.has(identity)) {
|
|
1651
|
+
framesByIdentity.set(identity, childFrame);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
catch {
|
|
1656
|
+
// Detached or cross-navigation frame owners fail closed. In particular,
|
|
1657
|
+
// never shift the next child frame into this placeholder's slot.
|
|
1658
|
+
}
|
|
1659
|
+
finally {
|
|
1660
|
+
await handle?.dispose().catch(() => { });
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return framesByIdentity;
|
|
1664
|
+
}
|
|
1665
|
+
async function captureFrameGeometry(frame, trackDocumentIdentity = false) {
|
|
1666
|
+
const documentIdentityBefore = trackDocumentIdentity
|
|
1667
|
+
? await currentHostDocumentIdentity(frame)
|
|
1668
|
+
: undefined;
|
|
1669
|
+
const childFramesByIdentity = await identifyChildFrames(frame);
|
|
948
1670
|
const raw = await frame.evaluate(browserExtractGeometry);
|
|
1671
|
+
const tree = raw.tree;
|
|
1672
|
+
const documentIdentityAfter = trackDocumentIdentity
|
|
1673
|
+
? await currentHostDocumentIdentity(frame)
|
|
1674
|
+
: undefined;
|
|
1675
|
+
if (documentIdentityBefore !== documentIdentityAfter) {
|
|
1676
|
+
throw new Error('Document changed during navigation geometry extraction');
|
|
1677
|
+
}
|
|
949
1678
|
return {
|
|
950
1679
|
layout: raw.layout,
|
|
951
|
-
tree
|
|
1680
|
+
tree,
|
|
1681
|
+
childFramesByIdentity,
|
|
1682
|
+
documentIdentity: documentIdentityAfter,
|
|
952
1683
|
};
|
|
953
1684
|
}
|
|
1685
|
+
async function mergeIframesForOwner(tree, layout, ownerFrame, framesByIdentity) {
|
|
1686
|
+
const ownerOrigin = await frameOriginInRootPage(ownerFrame);
|
|
1687
|
+
async function visit(t, l) {
|
|
1688
|
+
if (t.semantic?.tag === 'iframe') {
|
|
1689
|
+
const identity = typeof t.semantic.iframeIdentity === 'string'
|
|
1690
|
+
? t.semantic.iframeIdentity
|
|
1691
|
+
: undefined;
|
|
1692
|
+
// Internal correlation tokens must never leak into the public snapshot,
|
|
1693
|
+
// including for detached or otherwise unmatched owners.
|
|
1694
|
+
if ('iframeIdentity' in t.semantic)
|
|
1695
|
+
delete t.semantic.iframeIdentity;
|
|
1696
|
+
t.children = [];
|
|
1697
|
+
l.children = [];
|
|
1698
|
+
if (!identity)
|
|
1699
|
+
return;
|
|
1700
|
+
const childFrame = framesByIdentity.get(identity);
|
|
1701
|
+
if (!childFrame || childFrame.isDetached())
|
|
1702
|
+
return;
|
|
1703
|
+
try {
|
|
1704
|
+
const child = await captureFrameGeometry(childFrame);
|
|
1705
|
+
await mergeIframesForOwner(child.tree, child.layout, childFrame, child.childFramesByIdentity);
|
|
1706
|
+
const childOrigin = await frameOriginInRootPage(childFrame);
|
|
1707
|
+
const deltaX = childOrigin.x - ownerOrigin.x;
|
|
1708
|
+
const deltaY = childOrigin.y - ownerOrigin.y;
|
|
1709
|
+
l.children = child.layout.children.map(childLayout => {
|
|
1710
|
+
const copy = cloneLayout(childLayout);
|
|
1711
|
+
offsetLayoutSubtree(copy, deltaX, deltaY);
|
|
1712
|
+
return copy;
|
|
1713
|
+
});
|
|
1714
|
+
t.children = (child.tree.children ?? []).map(cloneTree);
|
|
1715
|
+
t.semantic = { ...t.semantic, frameUrl: childFrame.url() };
|
|
1716
|
+
}
|
|
1717
|
+
catch {
|
|
1718
|
+
// A navigation/detach between identity capture and frame extraction is
|
|
1719
|
+
// an unmatched placeholder, not permission to borrow another frame.
|
|
1720
|
+
}
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const children = t.children ?? [];
|
|
1724
|
+
for (let index = 0; index < children.length; index++) {
|
|
1725
|
+
const childLayout = l.children[index];
|
|
1726
|
+
if (childLayout)
|
|
1727
|
+
await visit(children[index], childLayout);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
await visit(tree, layout);
|
|
1731
|
+
}
|
|
954
1732
|
export async function extractFrameGeometry(frame) {
|
|
955
1733
|
const { tree, layout } = await captureFrameGeometry(frame);
|
|
956
1734
|
return {
|
|
@@ -960,40 +1738,7 @@ export async function extractFrameGeometry(frame) {
|
|
|
960
1738
|
};
|
|
961
1739
|
}
|
|
962
1740
|
export async function mergeAllIframes(tree, layout, ownerFrame) {
|
|
963
|
-
|
|
964
|
-
return;
|
|
965
|
-
async function dfs(t, l, f) {
|
|
966
|
-
const subFrames = f.childFrames();
|
|
967
|
-
if (subFrames.length === 0)
|
|
968
|
-
return;
|
|
969
|
-
let slot = 0;
|
|
970
|
-
const tch = t.children ?? [];
|
|
971
|
-
const lch = l.children;
|
|
972
|
-
for (let i = 0; i < tch.length; i++) {
|
|
973
|
-
const st = tch[i];
|
|
974
|
-
const sl = lch[i];
|
|
975
|
-
if (st.semantic?.tag === 'iframe') {
|
|
976
|
-
const cf = subFrames[slot++];
|
|
977
|
-
if (cf && !cf.isDetached()) {
|
|
978
|
-
const snap = await captureFrameGeometry(cf);
|
|
979
|
-
await mergeAllIframes(snap.tree, snap.layout, cf);
|
|
980
|
-
const { x: ox, y: oy } = await frameOriginInRootPage(cf);
|
|
981
|
-
sl.children = snap.layout.children.map(c => {
|
|
982
|
-
const copy = cloneLayout(c);
|
|
983
|
-
offsetLayoutSubtree(copy, ox, oy);
|
|
984
|
-
return copy;
|
|
985
|
-
});
|
|
986
|
-
st.children = (snap.tree.children ?? []).map(cloneTree);
|
|
987
|
-
st.semantic = { ...st.semantic, frameUrl: cf.url() };
|
|
988
|
-
await dfs(st, sl, cf);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
else {
|
|
992
|
-
await dfs(st, sl, f);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
await dfs(tree, layout, ownerFrame);
|
|
1741
|
+
await mergeIframesForOwner(tree, layout, ownerFrame, await identifyChildFrames(ownerFrame));
|
|
997
1742
|
}
|
|
998
1743
|
/**
|
|
999
1744
|
* Full page: main frame + every nested iframe (any origin) + optional CDP AX name enrichment.
|
|
@@ -1002,7 +1747,7 @@ export async function extractGeometry(page, options) {
|
|
|
1002
1747
|
const trace = options?.trace;
|
|
1003
1748
|
const totalStartedAt = performance.now();
|
|
1004
1749
|
const mainFrameStartedAt = performance.now();
|
|
1005
|
-
const mainFrame = await captureFrameGeometry(page.mainFrame());
|
|
1750
|
+
const mainFrame = await captureFrameGeometry(page.mainFrame(), true);
|
|
1006
1751
|
if (trace) {
|
|
1007
1752
|
trace.mainFrameMs = performance.now() - mainFrameStartedAt;
|
|
1008
1753
|
trace.iframeCount = Math.max(0, page.frames().length - 1);
|
|
@@ -1013,22 +1758,91 @@ export async function extractGeometry(page, options) {
|
|
|
1013
1758
|
treeJson: '',
|
|
1014
1759
|
};
|
|
1015
1760
|
const iframeMergeStartedAt = performance.now();
|
|
1016
|
-
await
|
|
1761
|
+
await mergeIframesForOwner(main.tree, main.layout, page.mainFrame(), mainFrame.childFramesByIdentity);
|
|
1017
1762
|
if (trace) {
|
|
1018
1763
|
trace.iframeMergeMs = performance.now() - iframeMergeStartedAt;
|
|
1019
1764
|
}
|
|
1020
1765
|
const axDecisionStartedAt = performance.now();
|
|
1021
|
-
const
|
|
1766
|
+
const axSessionManager = axSessionManagerForPage(page, options?.axSessionManager);
|
|
1767
|
+
const axPlan = planAxProbe(page, mainFrame.documentIdentity, main, axSessionManager.revisionToken());
|
|
1768
|
+
const shouldRunAxEnrichment = axPlan.shouldRun;
|
|
1769
|
+
const runAxInBackground = shouldRunAxEnrichment;
|
|
1770
|
+
const requestAxRetry = (delayMs) => {
|
|
1771
|
+
if (!delayMs || !Number.isFinite(delayMs) || delayMs <= 0 || !options?.onAxRetryDue) {
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
try {
|
|
1775
|
+
options.onAxRetryDue(delayMs);
|
|
1776
|
+
return true;
|
|
1777
|
+
}
|
|
1778
|
+
catch {
|
|
1779
|
+
// Retry scheduling is advisory and must never poison extraction.
|
|
1780
|
+
return false;
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1022
1783
|
if (trace) {
|
|
1023
1784
|
trace.axDecisionMs = performance.now() - axDecisionStartedAt;
|
|
1024
|
-
trace.axRan = shouldRunAxEnrichment;
|
|
1785
|
+
trace.axRan = shouldRunAxEnrichment && !runAxInBackground;
|
|
1786
|
+
trace.axBackgroundStarted = runAxInBackground;
|
|
1787
|
+
trace.axFirstDocumentProbe = axPlan.firstDocumentProbe;
|
|
1788
|
+
if (axPlan.cacheReplaySuppressed)
|
|
1789
|
+
trace.axCacheReplaySuppressed = true;
|
|
1790
|
+
}
|
|
1791
|
+
if (requestAxRetry(axPlan.retryAfterMs) && axPlan.state) {
|
|
1792
|
+
axPlan.state.automaticRetryUsed = true;
|
|
1025
1793
|
}
|
|
1026
|
-
if (
|
|
1794
|
+
if (runAxInBackground) {
|
|
1795
|
+
const backgroundSnapshot = {
|
|
1796
|
+
tree: cloneTree(main.tree),
|
|
1797
|
+
layout: cloneLayout(main.layout),
|
|
1798
|
+
treeJson: '',
|
|
1799
|
+
};
|
|
1027
1800
|
const axEnrichStartedAt = performance.now();
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1801
|
+
void runAxProbeAndUpdateState(page, backgroundSnapshot, axPlan.state, axPlan.geometryFingerprint, axPlan.semanticFingerprint, axSessionManager).then(result => {
|
|
1802
|
+
if (trace) {
|
|
1803
|
+
trace.axProbeSucceeded = result.succeeded;
|
|
1804
|
+
trace.axEnrichMs = performance.now() - axEnrichStartedAt;
|
|
1805
|
+
}
|
|
1806
|
+
if (result.cacheReady) {
|
|
1807
|
+
try {
|
|
1808
|
+
options?.onAxReady?.();
|
|
1809
|
+
}
|
|
1810
|
+
catch {
|
|
1811
|
+
// Readiness is advisory; scheduler errors must not poison AX state.
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (requestAxRetry(result.retryAfterMs) &&
|
|
1815
|
+
axPlan.state &&
|
|
1816
|
+
axDocumentStateByPage.get(page) === axPlan.state) {
|
|
1817
|
+
axPlan.state.automaticRetryUsed = true;
|
|
1818
|
+
}
|
|
1819
|
+
}).catch(() => {
|
|
1820
|
+
// enrichSnapshotWithCdpAx is fail-soft, but keep the detached background
|
|
1821
|
+
// task from ever becoming an unhandled rejection if that contract changes.
|
|
1822
|
+
if (axPlan.state && axDocumentStateByPage.get(page) === axPlan.state) {
|
|
1823
|
+
axPlan.state.probeStatus = 'failed';
|
|
1824
|
+
axPlan.state.lastAxProbeAtMs = performance.now();
|
|
1825
|
+
axPlan.state.refreshingWithSafeCache = false;
|
|
1826
|
+
axPlan.state.cacheInvalidated = true;
|
|
1827
|
+
clearAxCache(axPlan.state);
|
|
1828
|
+
}
|
|
1829
|
+
if (trace)
|
|
1830
|
+
trace.axProbeSucceeded = false;
|
|
1831
|
+
if (requestAxRetry(AX_HEURISTIC_MIN_INTERVAL_MS) && axPlan.state) {
|
|
1832
|
+
axPlan.state.automaticRetryUsed = true;
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
if (axPlan.state && axPlan.mayReplayCache) {
|
|
1837
|
+
const replayed = replayCachedAxSemanticPatches(main, axPlan.state) +
|
|
1838
|
+
replayCachedAxAppendNodes(main, axPlan.state);
|
|
1839
|
+
if (trace)
|
|
1840
|
+
trace.axCacheReplayedCount = replayed;
|
|
1841
|
+
}
|
|
1842
|
+
else if (trace && (axPlan.cacheReplaySuppressed ||
|
|
1843
|
+
axPlan.state?.cachedAppendNodes.length ||
|
|
1844
|
+
axPlan.state?.cachedSemanticPatches.length)) {
|
|
1845
|
+
trace.axCacheReplaySuppressed = true;
|
|
1032
1846
|
}
|
|
1033
1847
|
const treeJsonStartedAt = performance.now();
|
|
1034
1848
|
main.treeJson = JSON.stringify(main.tree);
|