@geometra/proxy 1.63.2 → 1.65.0

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/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,102 @@ 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();
15
+ const FORM_CONTROL_ROLES = new Set([
16
+ 'button',
17
+ 'checkbox',
18
+ 'combobox',
19
+ 'listbox',
20
+ 'radio',
21
+ 'searchbox',
22
+ 'slider',
23
+ 'spinbutton',
24
+ 'switch',
25
+ 'textbox',
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
+ }
11
108
  function textWithoutNestedControls(node) {
12
109
  if (node.nodeType === Node.TEXT_NODE)
13
110
  return node.textContent ?? '';
@@ -28,24 +125,27 @@ function browserExtractGeometry() {
28
125
  return aria.trim() || undefined;
29
126
  const labelledBy = el.getAttribute('aria-labelledby');
30
127
  if (labelledBy) {
31
- const resolveChain = (ids, visited) => ids
128
+ const resolveChainFrom = (owner, ids, visited) => ids
32
129
  .split(/\s+/)
33
130
  .map(id => {
34
131
  if (visited.has(id))
35
132
  return '';
36
133
  visited.add(id);
37
- const target = document.getElementById(id);
134
+ const target = rootScopedElementById(owner, id);
38
135
  if (!target)
39
136
  return '';
40
137
  const chained = target.getAttribute('aria-labelledby');
41
- if (chained)
42
- return resolveChain(chained, visited);
138
+ if (chained) {
139
+ const chainedText = resolveChainFrom(target, chained, visited);
140
+ if (chainedText)
141
+ return chainedText;
142
+ }
43
143
  return textWithoutNestedControls(target).replace(/\s+/g, ' ').trim();
44
144
  })
45
145
  .filter(Boolean)
46
146
  .join(' ')
47
147
  .trim();
48
- const text = resolveChain(labelledBy, new Set());
148
+ const text = resolveChainFrom(el, labelledBy, new Set());
49
149
  if (text)
50
150
  return text.length > 100 ? text.slice(0, 100) : text;
51
151
  }
@@ -57,7 +157,10 @@ function browserExtractGeometry() {
57
157
  return t;
58
158
  }
59
159
  if (el instanceof HTMLElement && el.id) {
60
- const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
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)}"]`);
61
164
  const t = label ? textWithoutNestedControls(label).replace(/\s+/g, ' ').trim() : '';
62
165
  if (t)
63
166
  return t;
@@ -183,7 +286,7 @@ function browserExtractGeometry() {
183
286
  return undefined;
184
287
  return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
185
288
  }
186
- function referencedText(ids, visited) {
289
+ function referencedText(owner, ids, visited) {
187
290
  if (!ids)
188
291
  return undefined;
189
292
  const seen = visited ?? new Set();
@@ -193,12 +296,12 @@ function browserExtractGeometry() {
193
296
  if (seen.has(id))
194
297
  return '';
195
298
  seen.add(id);
196
- const target = document.getElementById(id);
299
+ const target = rootScopedElementById(owner, id);
197
300
  if (!target)
198
301
  return '';
199
302
  const chained = target.getAttribute('aria-labelledby');
200
303
  if (chained)
201
- return referencedText(chained, seen) ?? '';
304
+ return referencedText(target, chained, seen) ?? '';
202
305
  return target.textContent?.trim() ?? '';
203
306
  })
204
307
  .filter(Boolean)
@@ -223,7 +326,7 @@ function browserExtractGeometry() {
223
326
  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
224
327
  try {
225
328
  if (el.willValidate)
226
- return !el.checkValidity();
329
+ return !el.validity.valid;
227
330
  }
228
331
  catch {
229
332
  return false;
@@ -238,18 +341,18 @@ function browserExtractGeometry() {
238
341
  return /\b(required|invalid|must|please|enter|select|choose|upload|missing|error)\b/i.test(value);
239
342
  }
240
343
  function controlErrorText(el) {
241
- const err = referencedText(el.getAttribute('aria-errormessage'));
344
+ const err = referencedText(el, el.getAttribute('aria-errormessage'));
242
345
  if (err)
243
346
  return err;
244
347
  if (!controlInvalid(el))
245
348
  return undefined;
246
- const described = referencedText(el.getAttribute('aria-describedby'));
349
+ const described = referencedText(el, el.getAttribute('aria-describedby'));
247
350
  if (described && looksLikeValidationText(described))
248
351
  return described;
249
352
  return undefined;
250
353
  }
251
354
  function controlDescriptionText(el) {
252
- const described = referencedText(el.getAttribute('aria-describedby'));
355
+ const described = referencedText(el, el.getAttribute('aria-describedby'));
253
356
  if (!described)
254
357
  return undefined;
255
358
  const error = controlErrorText(el);
@@ -467,12 +570,25 @@ function browserExtractGeometry() {
467
570
  return true;
468
571
  const h = el;
469
572
  const style = getComputedStyle(h);
470
- if (style.display === 'none' || style.visibility === 'hidden')
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'))
471
579
  return true;
472
580
  if (tag === 'input' && el.type === 'hidden')
473
581
  return true;
474
582
  const rect = h.getBoundingClientRect();
475
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;
476
592
  // Don't skip elements that carry an explicit interactive role or are
477
593
  // form-control elements. react-select v5 (and similar custom-combobox
478
594
  // libraries) renders the trigger as an `<input role="combobox">` with
@@ -504,7 +620,7 @@ function browserExtractGeometry() {
504
620
  if (!shouldSkip(c))
505
621
  out.push(c);
506
622
  }
507
- const sr = container.shadowRoot;
623
+ const sr = registeredShadowRootForHost(container);
508
624
  if (sr) {
509
625
  for (const c of sr.children) {
510
626
  if (!shouldSkip(c))
@@ -623,11 +739,73 @@ function browserExtractGeometry() {
623
739
  }
624
740
  return false;
625
741
  }
742
+ /**
743
+ * Preserve an authored identity for form controls so downstream consumers do
744
+ * not have to resolve a field by its (often duplicated) accessible label.
745
+ *
746
+ * `controlKey` is deliberately based only on authored attributes. We do not
747
+ * fall back to a DOM index/path because those change whenever a reactive form
748
+ * inserts, removes, or reorders conditional fields.
749
+ */
750
+ function addAuthoredControlIdentity(semantic, el, tag, role) {
751
+ const isNativeControl = el instanceof HTMLInputElement ||
752
+ el instanceof HTMLTextAreaElement ||
753
+ el instanceof HTMLSelectElement ||
754
+ el instanceof HTMLButtonElement;
755
+ const normalizedRole = role?.trim().toLowerCase();
756
+ if (!isNativeControl && (!normalizedRole || !FORM_CONTROL_ROLES.has(normalizedRole)))
757
+ return;
758
+ const controlId = el.getAttribute('id');
759
+ const controlName = el.getAttribute('name');
760
+ if (controlId?.trim())
761
+ semantic.controlId = controlId;
762
+ if (controlName?.trim())
763
+ semantic.controlName = controlName;
764
+ if (controlId?.trim()) {
765
+ semantic.controlKey = `id:${encodeURIComponent(controlId)}`;
766
+ return;
767
+ }
768
+ if (!controlName?.trim())
769
+ return;
770
+ // Use the authored attribute, not DOM defaults such as input.type="text",
771
+ // button.type="submit", or select.type="select-one". This keeps the key
772
+ // derivation identical in the extractor and action resolver.
773
+ const controlType = el.getAttribute('type')?.trim().toLowerCase() || 'default';
774
+ semantic.controlKey = `name:${tag}:${controlType}:${encodeURIComponent(controlName)}`;
775
+ }
776
+ /** Native select options are otherwise lost because <select> is a leaf node. */
777
+ function nativeSelectOptions(el) {
778
+ return Array.from(el.options).map((option, index) => ({
779
+ // Preserve the submitted value exactly. It can intentionally differ
780
+ // from the visible label (and may even be duplicated).
781
+ value: option.value,
782
+ label: option.label.replace(/\s+/g, ' ').trim(),
783
+ // HTMLOptionElement.disabled does not include a disabled <optgroup>.
784
+ disabled: option.disabled || (option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled),
785
+ selected: option.selected,
786
+ // Index is retained so two identical label/value pairs remain distinct.
787
+ index,
788
+ }));
789
+ }
626
790
  function semanticFor(el, tag) {
627
791
  const semantic = { tag };
628
792
  const role = defaultRoleForTag(el, tag);
629
793
  if (role)
630
794
  semantic.role = role;
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
+ }
807
+ if (el instanceof HTMLSelectElement)
808
+ semantic.options = nativeSelectOptions(el);
631
809
  const al = el.getAttribute('aria-label');
632
810
  if (al)
633
811
  semantic.ariaLabel = al;
@@ -678,6 +856,17 @@ function browserExtractGeometry() {
678
856
  semantic.ariaDisabled = true;
679
857
  if (h instanceof HTMLTextAreaElement && h.disabled)
680
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;
681
870
  const exp = el.getAttribute('aria-expanded');
682
871
  if (exp !== null)
683
872
  semantic.ariaExpanded = exp === 'true';
@@ -718,6 +907,16 @@ function browserExtractGeometry() {
718
907
  if (h instanceof HTMLInputElement && h.type === 'file') {
719
908
  sem.role = 'button';
720
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
+ }
721
920
  }
722
921
  if (name && !sem.ariaLabel)
723
922
  sem.ariaLabel = name;
@@ -749,6 +948,11 @@ function browserExtractGeometry() {
749
948
  function extractElement(el) {
750
949
  if (shouldSkip(el))
751
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;
752
956
  const tag = el.tagName.toLowerCase();
753
957
  if (tag === 'img' && el instanceof HTMLImageElement)
754
958
  return extractImage(el);
@@ -765,8 +969,14 @@ function browserExtractGeometry() {
765
969
  children: [],
766
970
  };
767
971
  const sem = semanticFor(iframe, 'iframe');
972
+ const name = getAccessibleName(iframe);
973
+ if (name && !sem.ariaLabel)
974
+ sem.ariaLabel = name;
768
975
  sem.iframe = true;
769
976
  sem.iframePlaceholder = true;
977
+ const iframeIdentity = iframeIdentityForElement(iframe);
978
+ if (iframeIdentity)
979
+ sem.iframeIdentity = iframeIdentity;
770
980
  return {
771
981
  layout,
772
982
  tree: {
@@ -842,7 +1052,12 @@ function browserExtractGeometry() {
842
1052
  const body = document.body;
843
1053
  if (!body) {
844
1054
  const emptyLayout = { x: 0, y: 0, width: 0, height: 0, children: [] };
845
- const emptyTree = { kind: 'box', props: {}, semantic: { tag: 'body' }, children: [] };
1055
+ const emptyTree = {
1056
+ kind: 'box',
1057
+ props: {},
1058
+ semantic: { tag: 'body' },
1059
+ children: [],
1060
+ };
846
1061
  return { layout: emptyLayout, tree: emptyTree };
847
1062
  }
848
1063
  const elementChildren = displayedElementChildren(body);
@@ -881,13 +1096,639 @@ function cloneLayout(layout) {
881
1096
  function cloneTree(tree) {
882
1097
  return structuredClone(tree);
883
1098
  }
884
- async function captureFrameGeometry(frame) {
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);
885
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
+ }
886
1678
  return {
887
1679
  layout: raw.layout,
888
- tree: raw.tree,
1680
+ tree,
1681
+ childFramesByIdentity,
1682
+ documentIdentity: documentIdentityAfter,
889
1683
  };
890
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
+ }
891
1732
  export async function extractFrameGeometry(frame) {
892
1733
  const { tree, layout } = await captureFrameGeometry(frame);
893
1734
  return {
@@ -897,40 +1738,7 @@ export async function extractFrameGeometry(frame) {
897
1738
  };
898
1739
  }
899
1740
  export async function mergeAllIframes(tree, layout, ownerFrame) {
900
- if (ownerFrame.childFrames().length === 0)
901
- return;
902
- async function dfs(t, l, f) {
903
- const subFrames = f.childFrames();
904
- if (subFrames.length === 0)
905
- return;
906
- let slot = 0;
907
- const tch = t.children ?? [];
908
- const lch = l.children;
909
- for (let i = 0; i < tch.length; i++) {
910
- const st = tch[i];
911
- const sl = lch[i];
912
- if (st.semantic?.tag === 'iframe') {
913
- const cf = subFrames[slot++];
914
- if (cf && !cf.isDetached()) {
915
- const snap = await captureFrameGeometry(cf);
916
- await mergeAllIframes(snap.tree, snap.layout, cf);
917
- const { x: ox, y: oy } = await frameOriginInRootPage(cf);
918
- sl.children = snap.layout.children.map(c => {
919
- const copy = cloneLayout(c);
920
- offsetLayoutSubtree(copy, ox, oy);
921
- return copy;
922
- });
923
- st.children = (snap.tree.children ?? []).map(cloneTree);
924
- st.semantic = { ...st.semantic, frameUrl: cf.url() };
925
- await dfs(st, sl, cf);
926
- }
927
- }
928
- else {
929
- await dfs(st, sl, f);
930
- }
931
- }
932
- }
933
- await dfs(tree, layout, ownerFrame);
1741
+ await mergeIframesForOwner(tree, layout, ownerFrame, await identifyChildFrames(ownerFrame));
934
1742
  }
935
1743
  /**
936
1744
  * Full page: main frame + every nested iframe (any origin) + optional CDP AX name enrichment.
@@ -939,7 +1747,7 @@ export async function extractGeometry(page, options) {
939
1747
  const trace = options?.trace;
940
1748
  const totalStartedAt = performance.now();
941
1749
  const mainFrameStartedAt = performance.now();
942
- const mainFrame = await captureFrameGeometry(page.mainFrame());
1750
+ const mainFrame = await captureFrameGeometry(page.mainFrame(), true);
943
1751
  if (trace) {
944
1752
  trace.mainFrameMs = performance.now() - mainFrameStartedAt;
945
1753
  trace.iframeCount = Math.max(0, page.frames().length - 1);
@@ -950,22 +1758,91 @@ export async function extractGeometry(page, options) {
950
1758
  treeJson: '',
951
1759
  };
952
1760
  const iframeMergeStartedAt = performance.now();
953
- await mergeAllIframes(main.tree, main.layout, page.mainFrame());
1761
+ await mergeIframesForOwner(main.tree, main.layout, page.mainFrame(), mainFrame.childFramesByIdentity);
954
1762
  if (trace) {
955
1763
  trace.iframeMergeMs = performance.now() - iframeMergeStartedAt;
956
1764
  }
957
1765
  const axDecisionStartedAt = performance.now();
958
- const shouldRunAxEnrichment = shouldEnrichSnapshotWithCdpAx(main);
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
+ };
959
1783
  if (trace) {
960
1784
  trace.axDecisionMs = performance.now() - axDecisionStartedAt;
961
- 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;
962
1790
  }
963
- if (shouldRunAxEnrichment) {
1791
+ if (requestAxRetry(axPlan.retryAfterMs) && axPlan.state) {
1792
+ axPlan.state.automaticRetryUsed = true;
1793
+ }
1794
+ if (runAxInBackground) {
1795
+ const backgroundSnapshot = {
1796
+ tree: cloneTree(main.tree),
1797
+ layout: cloneLayout(main.layout),
1798
+ treeJson: '',
1799
+ };
964
1800
  const axEnrichStartedAt = performance.now();
965
- await enrichSnapshotWithCdpAx(page, main, options?.axSessionManager);
966
- if (trace) {
967
- trace.axEnrichMs = performance.now() - axEnrichStartedAt;
968
- }
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;
969
1846
  }
970
1847
  const treeJsonStartedAt = performance.now();
971
1848
  main.treeJson = JSON.stringify(main.tree);