@geometra/proxy 1.64.0 → 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,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 resolveChain = (ids, visited) => ids
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 = document.getElementById(id);
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
- return resolveChain(chained, visited);
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 = resolveChain(labelledBy, new Set());
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 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)}"]`);
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 = document.getElementById(id);
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.checkValidity();
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
- 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'))
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.shadowRoot;
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
- addAuthoredControlIdentity(semantic, el, tag, 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
+ }
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 = { kind: 'box', props: {}, semantic: { tag: 'body' }, children: [] };
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
- 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);
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: raw.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
- if (ownerFrame.childFrames().length === 0)
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 mergeAllIframes(main.tree, main.layout, page.mainFrame());
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 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
+ };
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 (shouldRunAxEnrichment) {
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
- await enrichSnapshotWithCdpAx(page, main, options?.axSessionManager);
1029
- if (trace) {
1030
- trace.axEnrichMs = performance.now() - axEnrichStartedAt;
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);