agent-device-lab 0.0.0-stage → 0.3.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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +142 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +58 -4
@@ -0,0 +1,1445 @@
1
+ // Functions in this file are serialised by Playwright and run inside the page.
2
+ // They must be self-contained: no imports, no references to module scope.
3
+ export function extractPage(args) {
4
+ const KEY = '__agentDeviceLab_v1';
5
+ const w = window;
6
+ if (!w[KEY]) {
7
+ Object.defineProperty(w, KEY, {
8
+ enumerable: false,
9
+ value: { docId: Math.random().toString(36).slice(2, 10), byRef: new Map(), byEl: new WeakMap() },
10
+ });
11
+ }
12
+ const reg = w[KEY];
13
+ let nextRef = args.nextRef;
14
+ const clean = (s, max = 80) => {
15
+ const t = (s ?? '').replace(/\s+/g, ' ').trim();
16
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
17
+ };
18
+ const textOf = (el) => clean(el.innerText ?? el.textContent);
19
+ const INTERACTIVE = [
20
+ 'a[href]', 'button', 'input:not([type=hidden])', 'select', 'textarea', 'summary',
21
+ '[contenteditable=""]', '[contenteditable="true"]',
22
+ '[role=button]', '[role=link]', '[role=checkbox]', '[role=radio]', '[role=switch]', '[role=tab]',
23
+ '[role=menuitem]', '[role=option]', '[role=combobox]', '[role=textbox]', '[role=searchbox]', '[role=slider]',
24
+ '[tabindex]:not([tabindex="-1"])',
25
+ ].join(',');
26
+ const roleOf = (el) => {
27
+ const explicit = el.getAttribute('role')?.trim().split(/\s+/)[0];
28
+ if (explicit)
29
+ return explicit;
30
+ const tag = el.tagName.toLowerCase();
31
+ if (tag === 'a')
32
+ return 'link';
33
+ if (tag === 'button' || tag === 'summary')
34
+ return 'button';
35
+ if (tag === 'select')
36
+ return el.multiple ? 'listbox' : 'combobox';
37
+ if (tag === 'textarea')
38
+ return 'textbox';
39
+ if (tag === 'input') {
40
+ const type = el.type;
41
+ if (type === 'checkbox' || type === 'radio')
42
+ return type;
43
+ if (['button', 'submit', 'reset', 'image'].includes(type))
44
+ return 'button';
45
+ if (type === 'range')
46
+ return 'slider';
47
+ if (type === 'number')
48
+ return 'spinbutton';
49
+ if (type === 'search')
50
+ return 'searchbox';
51
+ return 'textbox';
52
+ }
53
+ if (el.isContentEditable)
54
+ return 'textbox';
55
+ return 'generic';
56
+ };
57
+ const nameOf = (el) => {
58
+ const labelledBy = el.getAttribute('aria-labelledby');
59
+ if (labelledBy) {
60
+ const t = clean(labelledBy.split(/\s+/).map((id) => { const n = document.getElementById(id); return n ? textOf(n) : ''; }).join(' '));
61
+ if (t)
62
+ return t;
63
+ }
64
+ const aria = clean(el.getAttribute('aria-label'));
65
+ if (aria)
66
+ return aria;
67
+ const tag = el.tagName.toLowerCase();
68
+ if (tag === 'input' || tag === 'select' || tag === 'textarea') {
69
+ const input = el;
70
+ if (['button', 'submit', 'reset'].includes(input.type))
71
+ return clean(input.value);
72
+ const label = input.labels?.[0];
73
+ if (label)
74
+ return textOf(label);
75
+ return clean(input.placeholder || input.title || input.name);
76
+ }
77
+ const text = textOf(el);
78
+ if (text)
79
+ return text;
80
+ const img = el.querySelector('img[alt]');
81
+ return clean(img?.getAttribute('alt') || el.getAttribute('title'));
82
+ };
83
+ const isShown = (el) => {
84
+ const r = el.getBoundingClientRect();
85
+ if (r.width < 1 || r.height < 1)
86
+ return false;
87
+ if (!el.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
88
+ return false;
89
+ return !el.closest('[aria-hidden="true"], [inert]');
90
+ };
91
+ const refFor = (el) => {
92
+ let ref = reg.byEl.get(el);
93
+ if (!ref) {
94
+ ref = `e${nextRef++}`;
95
+ reg.byEl.set(el, ref);
96
+ reg.byRef.set(ref, new WeakRef(el));
97
+ }
98
+ return ref;
99
+ };
100
+ // A modal dialog scopes observation: background controls are inert and reported only as a count.
101
+ const modal = Array.from(document.querySelectorAll('dialog[open], [role=dialog][aria-modal=true], [role=alertdialog]'))
102
+ .find((d) => isShown(d) && (d.matches(':modal') || d.getAttribute('aria-modal') === 'true' || d.getAttribute('role') === 'alertdialog'));
103
+ const scope = modal ?? document;
104
+ // vw is the device-width viewport pages are designed for. What the person actually sees is the visual
105
+ // viewport, which can be panned inside a wider layout viewport on mobile.
106
+ const vw = document.documentElement.clientWidth;
107
+ const vv = window.visualViewport;
108
+ const panX = vv ? vv.offsetLeft : 0;
109
+ const panY = vv ? vv.offsetTop : 0;
110
+ const visibleH = vv ? vv.height : window.innerHeight;
111
+ const sx = window.scrollX;
112
+ const sy = window.scrollY;
113
+ const active = document.activeElement;
114
+ const controls = [];
115
+ const elements = [];
116
+ let omitted = 0;
117
+ for (const el of Array.from(scope.querySelectorAll(INTERACTIVE))) {
118
+ if (!isShown(el))
119
+ continue;
120
+ // Skip elements nested inside another interactive element already captured (e.g. span[tabindex] in a button).
121
+ const parentControl = el.parentElement?.closest(INTERACTIVE);
122
+ if (parentControl && isShown(parentControl) && scope.contains(parentControl))
123
+ continue;
124
+ if (controls.length >= args.limit) {
125
+ omitted++;
126
+ continue;
127
+ }
128
+ const r = el.getBoundingClientRect();
129
+ const c = {
130
+ ref: refFor(el),
131
+ role: roleOf(el),
132
+ name: nameOf(el),
133
+ rect: { x: Math.round(r.left + sx), y: Math.round(r.top + sy), w: Math.round(r.width), h: Math.round(r.height) },
134
+ };
135
+ const input = el;
136
+ if ((el.tagName === 'INPUT' && c.role !== 'button') || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
137
+ if (c.role === 'checkbox' || c.role === 'radio')
138
+ c.checked = input.checked;
139
+ else
140
+ c.value = input.type === 'password' ? (input.value ? '••••' : '') : clean(input.value, 60);
141
+ if (input.required)
142
+ c.required = true;
143
+ }
144
+ else if (el.isContentEditable) {
145
+ c.value = textOf(el);
146
+ }
147
+ if (el.disabled || el.getAttribute('aria-disabled') === 'true')
148
+ c.disabled = true;
149
+ const ariaChecked = el.getAttribute('aria-checked');
150
+ if (ariaChecked)
151
+ c.checked = ariaChecked === 'true';
152
+ const ariaPressed = el.getAttribute('aria-pressed');
153
+ if (ariaPressed === 'true' || ariaPressed === 'false')
154
+ c.pressed = ariaPressed === 'true';
155
+ const expanded = el.getAttribute('aria-expanded');
156
+ if (expanded)
157
+ c.expanded = expanded === 'true';
158
+ const selected = el.getAttribute('aria-selected');
159
+ if (selected === 'true' || selected === 'false')
160
+ c.selected = selected === 'true';
161
+ if (el.getAttribute('aria-invalid') === 'true')
162
+ c.invalid = true;
163
+ if (el === active)
164
+ c.focused = true;
165
+ if (r.bottom <= panY)
166
+ c.offscreen = 'above';
167
+ else if (r.top >= panY + visibleH)
168
+ c.offscreen = 'below';
169
+ const docLeft = r.left + sx;
170
+ const docRight = r.right + sx;
171
+ // An intentional horizontal scroller (carousel, chip row, wide table wrapper) that fits the viewport
172
+ // keeps its overflow to itself: its items are reachable by scrolling it, not by panning the page.
173
+ let scroller = null;
174
+ for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
175
+ const ox = getComputedStyle(a).overflowX;
176
+ if ((ox === 'auto' || ox === 'scroll') && a.scrollWidth > a.clientWidth + 1) {
177
+ scroller = a;
178
+ break;
179
+ }
180
+ }
181
+ const sr = scroller?.getBoundingClientRect();
182
+ // Entirely off-screen inside a fixed layer, or entirely left of the page's origin: a closed
183
+ // off-canvas panel. No pan can reach it, so it is hidden by design, not cut off.
184
+ const offCanvas = () => {
185
+ if (docRight <= 1)
186
+ return true;
187
+ if (docLeft < vw - 1)
188
+ return false;
189
+ for (let a = el; a && a !== document.documentElement; a = a.parentElement)
190
+ if (getComputedStyle(a).position === 'fixed')
191
+ return true;
192
+ return false;
193
+ };
194
+ if (sr && sr.left + sx >= -1 && sr.right + sx <= vw + 1)
195
+ c.scrollRegion = true;
196
+ else if ((docRight > vw + 1 || docLeft < -1) && offCanvas()) { /* hidden by design */ }
197
+ else if (docRight > vw + 1)
198
+ c.clip = { side: 'right', px: Math.round(docRight - vw) };
199
+ else if (docLeft < -1)
200
+ c.clip = { side: 'left', px: Math.round(-docLeft) };
201
+ controls.push(c);
202
+ elements.push(el);
203
+ }
204
+ // Repeated labels ("View profile" ×3) are ambiguous to an agent; a person tells them apart by the
205
+ // card they sit in. Give each duplicate the heading of the nearest ancestor that holds no twin.
206
+ const key = (c) => `${c.role}\u0000${c.name}`;
207
+ const counts = new Map();
208
+ for (const c of controls)
209
+ counts.set(key(c), (counts.get(key(c)) ?? 0) + 1);
210
+ controls.forEach((c, i) => {
211
+ const el = elements[i];
212
+ if ((counts.get(key(c)) ?? 0) < 2) {
213
+ // A lone control inside a card or list item ("View profile" when one result is left) still says
214
+ // nothing about what it belongs to: name the card by its heading.
215
+ const card = el.closest('article, li, [role=listitem], [role=article]');
216
+ const title = card && card !== el ? Array.from(card.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=heading]')).find((h) => !h.contains(el) && !el.contains(h) && isShown(h) && textOf(h)) : undefined;
217
+ if (title && !c.name.includes(textOf(title).slice(0, 60)))
218
+ c.context = textOf(title).slice(0, 60);
219
+ return;
220
+ }
221
+ const twins = elements.filter((other, j) => j !== i && key(controls[j]) === key(c));
222
+ // Twin-free ancestors, nearest first: prefer a heading from any of them (the card's title); only
223
+ // when there is none, use a short text label placed before the control (e.g. a day name).
224
+ const scopes = [];
225
+ for (let anc = el.parentElement, depth = 0; anc && anc !== document.body && depth < 8; anc = anc.parentElement, depth++) {
226
+ if (twins.some((t) => anc.contains(t)))
227
+ break;
228
+ scopes.push(anc);
229
+ }
230
+ const heading = scopes.map((anc) => Array.from(anc.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=heading], legend'))
231
+ .find((h) => !h.contains(el) && isShown(h) && textOf(h))).find(Boolean);
232
+ const label = heading ?? scopes.map((anc) => Array.from(anc.querySelectorAll('*')).find((n) => !n.contains(el) && !n.closest(INTERACTIVE) && (n.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 &&
233
+ Array.from(n.childNodes).some((t) => t.nodeType === Node.TEXT_NODE && t.textContent.trim()) &&
234
+ isShown(n) && textOf(n).length <= 60)).find(Boolean);
235
+ if (label)
236
+ c.context = textOf(label).slice(0, 60);
237
+ });
238
+ const inert = [];
239
+ if (modal) {
240
+ for (const el of Array.from(document.querySelectorAll(INTERACTIVE))) {
241
+ if (modal.contains(el))
242
+ continue;
243
+ const ref = reg.byEl.get(el);
244
+ if (ref && el.getBoundingClientRect().width > 0)
245
+ inert.push({ ref, role: roleOf(el), name: nameOf(el) });
246
+ }
247
+ }
248
+ const headings = Array.from(document.querySelectorAll('h1, h2'))
249
+ .filter((h) => isShown(h) && (!modal || !modal.contains(h)))
250
+ .slice(0, 6)
251
+ .map((h) => `${h.tagName.toLowerCase()} ${textOf(h)}`);
252
+ const messages = Array.from(document.querySelectorAll('[role=alert], [role=status], [aria-live]:not([aria-live=off]), output'))
253
+ .filter((m) => isShown(m) && textOf(m))
254
+ .slice(0, 8)
255
+ .map((m) => ({ role: m.getAttribute('role') ?? (m.tagName === 'OUTPUT' ? 'status' : 'live'), text: textOf(m).slice(0, 120) }));
256
+ return {
257
+ docId: reg.docId,
258
+ nextRef,
259
+ url: location.href,
260
+ title: document.title,
261
+ viewport: { width: vw, height: document.documentElement.clientHeight },
262
+ layoutViewportWidth: window.innerWidth,
263
+ scroll: { x: Math.round(sx + panX), y: Math.round(sy + panY) },
264
+ documentWidth: document.documentElement.scrollWidth,
265
+ headings,
266
+ dialog: modal ? (nameOf(modal) || 'dialog') : undefined,
267
+ controls,
268
+ omitted,
269
+ inert,
270
+ messages,
271
+ focused: active ? reg.byEl.get(active) : undefined,
272
+ };
273
+ }
274
+ /** In-page: look up a ref in the registry. Returns the element separately via evaluateHandle. */
275
+ export function lookupRef(ref) {
276
+ const reg = window['__agentDeviceLab_v1'];
277
+ const el = reg?.byRef.get(ref)?.deref();
278
+ return el && el.isConnected ? el : null;
279
+ }
280
+ export function currentDocId() {
281
+ return window['__agentDeviceLab_v1']?.docId ?? null;
282
+ }
283
+ /**
284
+ * In-page: centre the element in the view. Used when its centre is under a fixed or sticky bar (a
285
+ * bottom tab bar, a sticky header): a person would scroll a little further, so the lab does too.
286
+ */
287
+ export function centreInView(el) {
288
+ const vv = window.visualViewport;
289
+ const pan = () => ({ x: window.scrollX + (vv ? vv.offsetLeft : 0), y: window.scrollY + (vv ? vv.offsetTop : 0) });
290
+ const before = pan();
291
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
292
+ const after = pan();
293
+ return { dx: Math.round(after.x - before.x), dy: Math.round(after.y - before.y) };
294
+ }
295
+ /** In-page: reset the page to its top-left corner without smooth scrolling (pages may set scroll-behavior: smooth). */
296
+ export function scrollToOrigin() {
297
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
298
+ }
299
+ /** In-page: bring the element into the visual viewport if needed. Returns how far the view panned. */
300
+ export function panIntoView(el) {
301
+ const vv = window.visualViewport;
302
+ const pan = () => ({ x: window.scrollX + (vv ? vv.offsetLeft : 0), y: window.scrollY + (vv ? vv.offsetTop : 0) });
303
+ const before = pan();
304
+ const r = el.getBoundingClientRect();
305
+ const left = vv ? vv.offsetLeft : 0;
306
+ const top = vv ? vv.offsetTop : 0;
307
+ const width = vv ? vv.width : window.innerWidth;
308
+ const height = vv ? vv.height : window.innerHeight;
309
+ if (r.left < left || r.right > left + width || r.top < top || r.bottom > top + height) {
310
+ el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
311
+ }
312
+ const after = pan();
313
+ return { dx: Math.round(after.x - before.x), dy: Math.round(after.y - before.y) };
314
+ }
315
+ /** In-page: would reaching this element need a sideways pan of the visual viewport? (No side effects.) */
316
+ export function needsHorizontalPan(el) {
317
+ const vv = window.visualViewport;
318
+ const r = el.getBoundingClientRect();
319
+ const left = vv ? vv.offsetLeft : 0;
320
+ const width = vv ? vv.width : window.innerWidth;
321
+ return r.left < left || r.right > left + width;
322
+ }
323
+ /** In-page: where a finger or pointer would land for this element, and what is actually there. */
324
+ export function targetPoint(el) {
325
+ const vv = window.visualViewport;
326
+ // An inline link wrapped over two lines has its box centre between the lines, on the text around
327
+ // it; a person taps one of its line boxes, so aim at the centre of the largest one.
328
+ const lines = Array.from(el.getClientRects()).filter((b) => b.width > 0 && b.height > 0);
329
+ const r = lines.length > 1 ? lines.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a)) : el.getBoundingClientRect();
330
+ const cx = r.left + r.width / 2;
331
+ const cy = r.top + r.height / 2;
332
+ const scale = vv ? vv.scale : 1;
333
+ const x = (cx - (vv ? vv.offsetLeft : 0)) * scale;
334
+ const y = (cy - (vv ? vv.offsetTop : 0)) * scale;
335
+ const inView = x >= 0 && y >= 0 && x <= (vv ? vv.width * scale : window.innerWidth) && y <= (vv ? vv.height * scale : window.innerHeight);
336
+ const hit = document.elementFromPoint(cx, cy);
337
+ const hitOk = !!hit && (hit === el || el.contains(hit) || (hit instanceof HTMLLabelElement && hit.control === el));
338
+ let hitFixed = false;
339
+ for (let n = hit && !hitOk ? hit : null; n && n !== document.documentElement; n = n.parentElement) {
340
+ const pos = getComputedStyle(n).position;
341
+ if (pos === 'fixed' || pos === 'sticky') {
342
+ hitFixed = true;
343
+ break;
344
+ }
345
+ }
346
+ return { x, y, inView, hitOk, ...(hit && !hitOk ? { hit: describeElement(hit), hitFixed } : {}) };
347
+ function describeElement(n) {
348
+ const label = (n.getAttribute('aria-label') || n.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 40);
349
+ const id = n.id ? `#${n.id}` : '';
350
+ const cls = typeof n.className === 'string' && n.className.trim() ? `.${n.className.trim().split(/\s+/).join('.')}` : '';
351
+ return `${n.tagName.toLowerCase()}${id}${cls}${label ? ` "${label}"` : ''}`;
352
+ }
353
+ }
354
+ /**
355
+ * In-page: report the next pointerdown and click to the lab through an exposed binding, saying whether
356
+ * they reached `el`. Uses a binding (not page state) so delivery survives a navigation the click causes.
357
+ */
358
+ export function armHitCapture(el, token) {
359
+ const report = window['__agentDeviceLabHit'];
360
+ if (!report)
361
+ return;
362
+ for (const type of ['pointerdown', 'click']) {
363
+ const listener = (e) => {
364
+ const t = e.target;
365
+ const onTarget = !!t && (t === el || el.contains(t) || (t instanceof HTMLLabelElement && t.control === el));
366
+ const desc = t ? `${t.tagName.toLowerCase()}${t.id ? `#${t.id}` : ''}${t.innerText ? ` "${t.innerText.replace(/\s+/g, ' ').trim().slice(0, 40)}"` : ''}` : 'nothing';
367
+ report({ token, type, onTarget, target: desc });
368
+ };
369
+ document.addEventListener(type, listener, { capture: true, once: true });
370
+ setTimeout(() => document.removeEventListener(type, listener, { capture: true }), 3000);
371
+ }
372
+ }
373
+ /** In-page: describe why an element can or cannot be filled. */
374
+ export function fillability(el) {
375
+ const disabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
376
+ if (el.isContentEditable)
377
+ return { fillable: true, disabled };
378
+ if (el.tagName === 'TEXTAREA')
379
+ return { fillable: !el.readOnly, disabled, reason: 'read-only' };
380
+ if (el.tagName === 'INPUT') {
381
+ const input = el;
382
+ const nonText = ['checkbox', 'radio', 'button', 'submit', 'reset', 'image', 'file', 'range', 'color'];
383
+ if (nonText.includes(input.type))
384
+ return { fillable: false, disabled, reason: `input type=${input.type} is not a text field` };
385
+ if (input.readOnly)
386
+ return { fillable: false, disabled, reason: 'read-only' };
387
+ // Values typed here are never written to logs, reproduction steps or reports.
388
+ const secret = input.type === 'password' || /(password|one-time-code|cc-number|cc-csc)/i.test(input.autocomplete || '');
389
+ return { fillable: true, disabled, ...(secret ? { secret } : {}) };
390
+ }
391
+ return { fillable: false, disabled, reason: `<${el.tagName.toLowerCase()}> is not a text field` };
392
+ }
393
+ /** In-page: is this a checkbox, radio or switch, and what state is it in? */
394
+ export function checkability(el) {
395
+ const disabled = el.disabled === true || el.getAttribute('aria-disabled') === 'true';
396
+ if (el.tagName === 'INPUT') {
397
+ const input = el;
398
+ if (input.type === 'checkbox' || input.type === 'radio')
399
+ return { kind: input.type, checked: input.checked, disabled };
400
+ return { checked: false, disabled };
401
+ }
402
+ const role = el.getAttribute('role');
403
+ if (role === 'checkbox' || role === 'radio' || role === 'switch' || role === 'menuitemcheckbox' || role === 'menuitemradio') {
404
+ const kind = role === 'switch' ? 'switch' : role.endsWith('radio') ? 'radio' : 'checkbox';
405
+ return { kind, checked: el.getAttribute('aria-checked') === 'true', disabled };
406
+ }
407
+ return { checked: false, disabled };
408
+ }
409
+ /** In-page: a native <select> and its options (labels bounded), or why it is not one. */
410
+ export function selectInfo(el) {
411
+ if (el.tagName !== 'SELECT')
412
+ return { select: false, disabled: false, multiple: false, options: [] };
413
+ const sel = el;
414
+ return {
415
+ select: true,
416
+ disabled: sel.disabled,
417
+ multiple: sel.multiple,
418
+ options: Array.from(sel.options).slice(0, 200).map((o) => ({
419
+ value: o.value, label: (o.label || o.text).replace(/\s+/g, ' ').trim().slice(0, 80), selected: o.selected, disabled: o.disabled,
420
+ })),
421
+ };
422
+ }
423
+ /** In-page: is this a file input (directly, or through its label)? */
424
+ export function uploadInfo(el) {
425
+ const input = el.tagName === 'INPUT' ? el : el instanceof HTMLLabelElement ? el.control : null;
426
+ if (input && input.type === 'file')
427
+ return { fileInput: true, multiple: input.multiple, disabled: input.disabled };
428
+ return { fileInput: false, multiple: false, disabled: el.disabled === true };
429
+ }
430
+ /** In-page: is the focused element a password-like field? Keys pressed there are masked in history. */
431
+ export function focusedIsSecret() {
432
+ const el = document.activeElement;
433
+ if (!el || el.tagName !== 'INPUT')
434
+ return false;
435
+ return el.type === 'password' || /(password|one-time-code|cc-number|cc-csc)/i.test(el.autocomplete || '');
436
+ }
437
+ /**
438
+ * In-page: scroll the page, or the nearest scrollable container of `el` along the axis, by (dx, dy)
439
+ * CSS px without smooth scrolling. Returns what scrolled and how far it actually moved.
440
+ */
441
+ export function scrollByAmount(args) {
442
+ const { el, dx, dy } = args;
443
+ const horizontal = dx !== 0;
444
+ const scrollable = (n) => {
445
+ const st = getComputedStyle(n);
446
+ const overflow = horizontal ? st.overflowX : st.overflowY;
447
+ const room = horizontal ? n.scrollWidth - n.clientWidth : n.scrollHeight - n.clientHeight;
448
+ return room > 1 && (overflow === 'auto' || overflow === 'scroll');
449
+ };
450
+ let box = null;
451
+ for (let n = el ?? null; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
452
+ if (scrollable(n)) {
453
+ box = n;
454
+ break;
455
+ }
456
+ }
457
+ if (box) {
458
+ const before = { x: box.scrollLeft, y: box.scrollTop };
459
+ box.scrollBy({ left: dx, top: dy, behavior: 'instant' });
460
+ const moved = { x: Math.round(box.scrollLeft - before.x), y: Math.round(box.scrollTop - before.y) };
461
+ const label = box.getAttribute('aria-label') || box.id || box.tagName.toLowerCase();
462
+ return { target: `region "${label.slice(0, 40)}"`, moved, atEnd: moved.x === 0 && moved.y === 0 };
463
+ }
464
+ const vv = window.visualViewport;
465
+ const pos = () => ({ x: window.scrollX + (vv ? vv.offsetLeft : 0), y: window.scrollY + (vv ? vv.offsetTop : 0) });
466
+ const before = pos();
467
+ window.scrollBy({ left: dx, top: dy, behavior: 'instant' });
468
+ const after = pos();
469
+ const moved = { x: Math.round(after.x - before.x), y: Math.round(after.y - before.y) };
470
+ return { target: 'page', moved, atEnd: moved.x === 0 && moved.y === 0 };
471
+ }
472
+ /** In-page: scroll positions of the page and of every scrolled container, to measure what a gesture moved. */
473
+ export function scrollPositions() {
474
+ const out = { 'page.x': Math.round(window.scrollX), 'page.y': Math.round(window.scrollY) };
475
+ let i = 0;
476
+ for (const n of Array.from(document.querySelectorAll('body *'))) {
477
+ if (i >= 50)
478
+ break;
479
+ if (n.scrollWidth <= n.clientWidth + 1 && n.scrollHeight <= n.clientHeight + 1)
480
+ continue;
481
+ const label = (n.getAttribute('aria-label') || n.id || n.tagName.toLowerCase()).slice(0, 40);
482
+ out[`region "${label}".x`] = Math.round(n.scrollLeft);
483
+ out[`region "${label}".y`] = Math.round(n.scrollTop);
484
+ i++;
485
+ }
486
+ return out;
487
+ }
488
+ /**
489
+ * Init script (runs before page scripts in every document): track pending one-shot setTimeout timers
490
+ * so settling can wait for timer-driven work (in-memory mock APIs, debounces, delayed transitions)
491
+ * that neither mutates the DOM nor touches the network while it waits. setInterval is not tracked.
492
+ * Each timer records the depth of the timer chain that created it, so re-arming loops can be ignored.
493
+ */
494
+ export function installTimerTracking() {
495
+ const key = '__agentDeviceLab_timers';
496
+ if (window[key])
497
+ return;
498
+ const pending = new Map();
499
+ Object.defineProperty(window, key, { value: pending, enumerable: false });
500
+ const set = window.setTimeout.bind(window);
501
+ const clear = window.clearTimeout.bind(window);
502
+ let depth = 0;
503
+ const tracked = function (handler, delay, ...args) {
504
+ if (typeof handler !== 'function')
505
+ return set(handler, delay, ...args);
506
+ const d = Math.max(0, Number(delay) || 0);
507
+ const mine = depth;
508
+ let id = 0;
509
+ const run = function (...a) {
510
+ pending.delete(id);
511
+ const prev = depth;
512
+ depth = mine + 1;
513
+ try {
514
+ return handler.apply(this, a);
515
+ }
516
+ finally {
517
+ depth = prev;
518
+ }
519
+ };
520
+ id = set(run, d, ...args);
521
+ pending.set(id, { due: performance.now() + d, delay: d, depth: mine });
522
+ return id;
523
+ };
524
+ window.setTimeout = tracked;
525
+ window.clearTimeout = ((id) => { if (id !== undefined)
526
+ pending.delete(id); clear(id); });
527
+ }
528
+ /**
529
+ * Init script: record same-document route changes (pushState, replaceState, popstate) and the last time
530
+ * the page's element structure changed (elements added or removed, or `hidden` toggled). A client-side
531
+ * router can change the address at once and render the new page later, e.g. React Router 7 renders a
532
+ * navigation inside a transition that React's scheduler runs through MessageChannel, with no timer,
533
+ * request or DOM change in between. Text-only changes (a button's busy label) do not count as the new
534
+ * page having rendered.
535
+ */
536
+ export function installNavTracking() {
537
+ const key = '__agentDeviceLab_nav';
538
+ if (window[key])
539
+ return;
540
+ // windowAt: when the page last asked for a new window (wall clock, comparable with the lab's Date.now()).
541
+ const state = { at: 0, path: location.pathname, structureAt: 0, windowAt: 0 };
542
+ Object.defineProperty(window, key, { value: state, enumerable: false });
543
+ // A new tab is delivered to the lab only after the browser has created it, which can be later than the
544
+ // opener page goes quiet; recording the request lets settling wait for it.
545
+ const opensWindow = (target) => !!target && !['_self', '_top', '_parent'].includes(target.toLowerCase());
546
+ const originalOpen = window.open;
547
+ window.open = function (...args) {
548
+ state.windowAt = Date.now();
549
+ return originalOpen.apply(window, args);
550
+ };
551
+ window.addEventListener('click', (e) => {
552
+ const a = e.defaultPrevented || !(e.target instanceof Element) ? null : e.target.closest('a[href], area[href]');
553
+ if (a && (opensWindow(a.target) || e.ctrlKey || e.metaKey || e.shiftKey))
554
+ state.windowAt = Date.now();
555
+ });
556
+ window.addEventListener('submit', (e) => {
557
+ const form = e.target;
558
+ const submitter = e.submitter;
559
+ if (!e.defaultPrevented && opensWindow(submitter?.formTarget || form?.target || ''))
560
+ state.windowAt = Date.now();
561
+ });
562
+ const mark = () => {
563
+ if (location.pathname === state.path)
564
+ return;
565
+ state.path = location.pathname;
566
+ state.at = performance.now();
567
+ };
568
+ for (const name of ['pushState', 'replaceState']) {
569
+ const original = history[name].bind(history);
570
+ history[name] = function (...args) {
571
+ const r = original(...args);
572
+ mark();
573
+ return r;
574
+ };
575
+ }
576
+ window.addEventListener('popstate', mark, true);
577
+ new MutationObserver((records) => {
578
+ for (const r of records) {
579
+ const structural = r.type === 'attributes' ||
580
+ [...Array.from(r.addedNodes), ...Array.from(r.removedNodes)].some((n) => n.nodeType === Node.ELEMENT_NODE);
581
+ if (structural) {
582
+ state.structureAt = performance.now();
583
+ return;
584
+ }
585
+ }
586
+ }).observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden'] });
587
+ }
588
+ /**
589
+ * In-page: resolve when the DOM has been quiet for quietMs (and nothing is busy, animating or on a short
590
+ * timer), or after maxMs. With `fromPath` (the path before the action): when the action changed the
591
+ * route within this document, also wait until the page's element structure has changed since then,
592
+ * so the old page is not observed under the new address.
593
+ */
594
+ export function waitForDomQuiet(opts) {
595
+ return new Promise((resolve) => {
596
+ const start = performance.now();
597
+ let last = start;
598
+ const mo = new MutationObserver(() => { last = performance.now(); });
599
+ mo.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
600
+ // A visible aria-busy region is the page saying it is still loading, even when nothing mutates
601
+ // (e.g. an app waiting on a timer or an in-memory mock API).
602
+ const busy = () => [...document.querySelectorAll('[aria-busy="true"]')].some((el) => el.getBoundingClientRect().width > 0 && el.checkVisibility({ visibilityProperty: true }));
603
+ // Finite running animations (route and drawer entrances) change what a person sees without DOM
604
+ // mutations; an element fading in from opacity 0 would otherwise be observed as invisible.
605
+ // Infinite ones (spinners, pulses) never finish and are ignored.
606
+ const animating = () => document.getAnimations().some((a) => {
607
+ if (a.playState !== 'running')
608
+ return false;
609
+ const end = a.effect?.getComputedTiming().endTime;
610
+ return typeof end === 'number' && Number.isFinite(end);
611
+ });
612
+ // Short one-shot timers still pending (see installTimerTracking); long chains are treated as loops.
613
+ const timers = window.__agentDeviceLab_timers;
614
+ const timersPending = () => {
615
+ if (!timers || opts.timerMaxMs <= 0)
616
+ return false;
617
+ const now = performance.now();
618
+ for (const t of timers.values())
619
+ if (t.delay <= opts.timerMaxMs && t.depth <= 3 && t.due >= now - 50)
620
+ return true;
621
+ return false;
622
+ };
623
+ // The route changed (away from the path the action started on) but the new page has not rendered.
624
+ const nav = window.__agentDeviceLab_nav;
625
+ const routePending = () => !!opts.fromPath && !!nav && nav.at > 0 && location.pathname !== opts.fromPath && nav.structureAt <= nav.at;
626
+ // The document has scripts but nothing is drawn yet: an app root still empty while its modules load
627
+ // and evaluate. A dev server serves every module separately, so a lazy route renders only after a
628
+ // chain of dynamic imports, with gaps of pure evaluation (no request, timer or DOM change) longer
629
+ // than quietMs. Once something is drawn this stops being asked. A document without scripts, or
630
+ // with content, is never pending.
631
+ const scripts = Array.from(document.scripts).some((x) => !/json|template|html|importmap|speculationrules/i.test(x.type));
632
+ let drawn = !scripts;
633
+ const emptyPending = () => {
634
+ if (drawn)
635
+ return false;
636
+ const b = document.body;
637
+ if (!b)
638
+ return false;
639
+ drawn = !!b.innerText.trim() || Array.from(b.querySelectorAll('*')).some((el) => {
640
+ const t = el.tagName.toLowerCase();
641
+ return t.includes('-') || ['img', 'svg', 'canvas', 'video', 'audio', 'iframe', 'object', 'embed', 'input', 'button', 'a', 'select', 'textarea'].includes(t);
642
+ });
643
+ return !drawn;
644
+ };
645
+ const tick = () => {
646
+ const now = performance.now();
647
+ const isBusy = busy();
648
+ const isTimer = timersPending();
649
+ const isRoute = routePending();
650
+ const isEmpty = emptyPending();
651
+ if (isBusy || isTimer || isRoute || isEmpty || animating())
652
+ last = Math.max(last, now - opts.quietMs + 20);
653
+ if (now - last >= opts.quietMs) {
654
+ mo.disconnect();
655
+ resolve('quiet');
656
+ }
657
+ else if (now - start >= opts.maxMs) {
658
+ mo.disconnect();
659
+ resolve(isBusy ? 'busy' : isTimer ? 'timers' : isRoute ? 'route' : isEmpty ? 'empty' : 'timeout');
660
+ }
661
+ else
662
+ setTimeout(tick, 20);
663
+ };
664
+ setTimeout(tick, 20);
665
+ });
666
+ }
667
+ /** In-page: measure the geometry every layout detector needs, in one pass (viewport coordinates). */
668
+ export function measureLayout(args) {
669
+ const reg = window['__agentDeviceLab_v1'];
670
+ const CONTROL = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=switch],[role=tab],[role=menuitem],[role=option],[role=combobox],[role=slider],[tabindex]:not([tabindex="-1"])';
671
+ const clean = (s, max = 60) => {
672
+ const t = (s ?? '').replace(/\s+/g, ' ').trim();
673
+ return t.length > max ? t.slice(0, max - 1) + '…' : t;
674
+ };
675
+ const vw = document.documentElement.clientWidth;
676
+ const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
677
+ const box = (r) => ({ x: Math.round(r.left * 10) / 10, y: Math.round(r.top * 10) / 10, w: Math.round(r.width * 10) / 10, h: Math.round(r.height * 10) / 10 });
678
+ const cs = (el) => getComputedStyle(el);
679
+ const roleOf = (el) => {
680
+ const explicit = el.getAttribute('role')?.trim().split(/\s+/)[0];
681
+ if (explicit)
682
+ return explicit;
683
+ const tag = el.tagName.toLowerCase();
684
+ if (tag === 'a')
685
+ return el.hasAttribute('href') ? 'link' : 'generic';
686
+ if (tag === 'button' || tag === 'summary')
687
+ return 'button';
688
+ if (tag === 'select')
689
+ return 'combobox';
690
+ if (tag === 'textarea')
691
+ return 'textbox';
692
+ if (tag === 'input') {
693
+ const type = el.type;
694
+ if (type === 'checkbox' || type === 'radio')
695
+ return type;
696
+ if (['button', 'submit', 'reset', 'image'].includes(type))
697
+ return 'button';
698
+ return type === 'range' ? 'slider' : 'textbox';
699
+ }
700
+ if (/^h[1-6]$/.test(tag))
701
+ return 'heading';
702
+ if (tag === 'dialog')
703
+ return 'dialog';
704
+ if (tag === 'img')
705
+ return 'img';
706
+ return el.matches(CONTROL) ? 'generic' : 'text';
707
+ };
708
+ const nameOf = (el) => {
709
+ const labelledBy = el.getAttribute('aria-labelledby');
710
+ if (labelledBy) {
711
+ const t = clean(labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? '').join(' '));
712
+ if (t)
713
+ return t;
714
+ }
715
+ const aria = clean(el.getAttribute('aria-label'));
716
+ if (aria)
717
+ return aria;
718
+ const input = el;
719
+ if (el.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes(input.type))
720
+ return clean(input.value);
721
+ if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
722
+ return clean(input.labels?.[0]?.textContent || input.placeholder || input.name);
723
+ return clean(el.innerText ?? el.textContent) || clean(el.querySelector('img[alt]')?.getAttribute('alt') || el.getAttribute('title'));
724
+ };
725
+ const selectorOf = (el) => {
726
+ const parts = [];
727
+ for (let n = el; n && n !== document.body && parts.length < 4; n = n.parentElement) {
728
+ let p = n.tagName.toLowerCase();
729
+ if (n.id && /^[A-Za-z][\w-]{0,30}$/.test(n.id)) {
730
+ parts.unshift(`${p}#${n.id}`);
731
+ break;
732
+ }
733
+ const cls = typeof n.className === 'string' ? n.className.trim().split(/\s+/).find((c) => /^[A-Za-z][\w-]{0,24}$/.test(c)) : undefined;
734
+ if (cls)
735
+ p += `.${cls}`;
736
+ const same = n.parentElement ? Array.from(n.parentElement.children).filter((c) => c.tagName === n.tagName) : [];
737
+ if (same.length > 1)
738
+ p += `:nth-of-type(${same.indexOf(n) + 1})`;
739
+ parts.unshift(p);
740
+ }
741
+ return parts.join(' > ').slice(0, 120);
742
+ };
743
+ const describe = (el) => {
744
+ const control = el.matches(CONTROL);
745
+ const ref = reg?.byEl.get(el);
746
+ return { role: roleOf(el), name: nameOf(el), selector: selectorOf(el), control, ...(ref ? { ref } : {}) };
747
+ };
748
+ const shown = (el) => {
749
+ const r = el.getBoundingClientRect();
750
+ return r.width >= 1 && r.height >= 1 && el.checkVisibility({ opacityProperty: true, visibilityProperty: true });
751
+ };
752
+ // Visually hidden on purpose (screen-reader-only text, skip links): never a layout defect.
753
+ const srOnly = (el) => {
754
+ for (let n = el; n && n !== document.body; n = n.parentElement) {
755
+ const r = n.getBoundingClientRect();
756
+ const s = cs(n);
757
+ if ((r.width <= 2 && r.height <= 2) || s.clip === 'rect(0px, 0px, 0px, 0px)' || /inset\(50%/.test(s.clipPath))
758
+ return true;
759
+ }
760
+ return false;
761
+ };
762
+ const hasOwnText = (el) => Array.from(el.childNodes).some((t) => t.nodeType === Node.TEXT_NODE && (t.textContent ?? '').trim());
763
+ const inter = (a, b) => ({
764
+ left: Math.max(a.left, b.left), top: Math.max(a.top, b.top), right: Math.min(a.right, b.right), bottom: Math.min(a.bottom, b.bottom),
765
+ });
766
+ const area = (r) => Math.max(0, r.right - r.left) * Math.max(0, r.bottom - r.top);
767
+ const hits = (el, x, y) => {
768
+ if (x < 0 || y < 0 || x >= vw || y >= vh)
769
+ return false;
770
+ const h = document.elementFromPoint(x, y);
771
+ return !!h && (h === el || el.contains(h) || (h instanceof HTMLLabelElement && h.control === el));
772
+ };
773
+ // A modal dialog or drawer scopes the checks: the background is inert while it is open.
774
+ const modalEl = Array.from(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true]'))
775
+ .find((d) => shown(d) && (d.matches(':modal') || d.getAttribute('aria-modal') === 'true' || d.getAttribute('role') === 'alertdialog'));
776
+ const scope = modalEl ?? document.body;
777
+ const all = Array.from(scope.querySelectorAll('*'));
778
+ const omitted = Math.max(0, all.length - args.maxElements);
779
+ const els = all.slice(0, args.maxElements).filter(shown);
780
+ const cap = (list) => list.slice(0, args.maxItems);
781
+ // ---- tap targets (every control, not only the observed ones) ----
782
+ const targets = [];
783
+ const controls = els.filter((el) => el.matches(CONTROL) && !(el.parentElement?.closest(CONTROL)) && !el.closest('[inert], [aria-hidden="true"]') &&
784
+ !el.disabled && el.getAttribute('aria-disabled') !== 'true' && cs(el).pointerEvents !== 'none' && !srOnly(el));
785
+ for (const el of controls) {
786
+ const s = cs(el);
787
+ const tag = el.tagName.toLowerCase();
788
+ // Inline: a link inside a sentence, its size set by the line of text around it.
789
+ const parent = el.parentElement;
790
+ const inline = s.display === 'inline' && !!parent && Array.from(parent.childNodes).some((t) => t.nodeType === Node.TEXT_NODE && (t.textContent ?? '').trim().length > 1);
791
+ // A native control whose size the user agent decides (an unstyled checkbox or radio).
792
+ const userAgent = tag === 'input' && ['checkbox', 'radio'].includes(el.type) && s.appearance !== 'none';
793
+ targets.push({ el: describe(el), box: box(el.getBoundingClientRect()), inline, userAgent });
794
+ }
795
+ // ---- clipping by overflow:hidden/clip ancestors, and text cut inside its own box ----
796
+ const clips = [];
797
+ const texts = [];
798
+ /**
799
+ * Content in motion: the element or an ancestor before `stop` has a running animation or transition that
800
+ * never ends (a marquee) or moves it (transform, translate, rotate, scale). What a moving strip clips
801
+ * changes every frame, so it is not a defect a person can be shown.
802
+ */
803
+ const movingBetween = (el, stop) => {
804
+ for (let n = el; n && n !== stop; n = n.parentElement) {
805
+ let anims = [];
806
+ try {
807
+ anims = n.getAnimations();
808
+ }
809
+ catch { /* not supported */ }
810
+ for (const a of anims) {
811
+ if (a.playState !== 'running')
812
+ continue;
813
+ if (a.effect?.getComputedTiming().iterations === Infinity)
814
+ return true;
815
+ const frames = a.effect?.getKeyframes?.() ?? [];
816
+ if (frames.some((k) => 'transform' in k || 'translate' in k || 'rotate' in k || 'scale' in k))
817
+ return true;
818
+ }
819
+ }
820
+ return false;
821
+ };
822
+ /** The nearest ancestor that clips with overflow hidden or clip (not the body, whose overflow is a scroll lock). */
823
+ const clippingAncestor = (el) => {
824
+ for (let a = el.parentElement; a && a !== document.documentElement && a !== document.body; a = a.parentElement) {
825
+ const s = cs(a);
826
+ if (s.overflowX === 'hidden' || s.overflowX === 'clip' || s.overflowY === 'hidden' || s.overflowY === 'clip')
827
+ return a;
828
+ }
829
+ return null;
830
+ };
831
+ const clipRectOf = (el) => {
832
+ let vis = el.getBoundingClientRect();
833
+ let clipper = '';
834
+ let clipperEl = null;
835
+ let peek = false;
836
+ for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
837
+ const s = cs(a);
838
+ const hx = s.overflowX === 'hidden' || s.overflowX === 'clip';
839
+ const hy = s.overflowY === 'hidden' || s.overflowY === 'clip';
840
+ if (!hx && !hy)
841
+ continue;
842
+ if (a === document.body)
843
+ continue; // body overflow:hidden is a scroll lock, not a clip
844
+ const r = a.getBoundingClientRect();
845
+ const inner = { left: r.left + a.clientLeft, top: r.top + a.clientTop, right: r.left + a.clientLeft + a.clientWidth, bottom: r.top + a.clientTop + a.clientHeight };
846
+ const next = { left: hx ? Math.max(vis.left, inner.left) : vis.left, right: hx ? Math.min(vis.right, inner.right) : vis.right,
847
+ top: hy ? Math.max(vis.top, inner.top) : vis.top, bottom: hy ? Math.min(vis.bottom, inner.bottom) : vis.bottom };
848
+ if (area(next) < area(vis) - 0.5 && !clipper) {
849
+ clipper = selectorOf(a);
850
+ clipperEl = a;
851
+ // A track of similar items side by side (slides, cards) inside the clipper: a peeking carousel.
852
+ const track = Array.from(a.querySelectorAll(':scope > *, :scope > * > *')).find((t) => t.children.length >= 3 && ['flex', 'grid', 'inline-flex'].includes(cs(t).display) && t.contains(el));
853
+ if (track) {
854
+ const ws = Array.from(track.children).map((c) => c.getBoundingClientRect().width).filter((w) => w > 0);
855
+ peek = ws.length >= 3 && Math.max(...ws) - Math.min(...ws) <= Math.max(...ws) * 0.2;
856
+ }
857
+ }
858
+ vis = next;
859
+ }
860
+ return { vis, clipper, peek, moving: clipperEl ? movingBetween(el, clipperEl) : false };
861
+ };
862
+ /** Some of the element's own text is drawn inside its padding box. Text moved wholly outside it (text-indent: -3000px image replacement, font-size 0) is hidden on purpose. */
863
+ const textInBox = (el) => {
864
+ const r = el.getBoundingClientRect();
865
+ const left = r.left + el.clientLeft, top = r.top + el.clientTop;
866
+ const right = left + el.clientWidth, bottom = top + el.clientHeight;
867
+ const range = document.createRange();
868
+ for (const n of Array.from(el.childNodes)) {
869
+ if (n.nodeType !== Node.TEXT_NODE || !(n.textContent ?? '').trim())
870
+ continue;
871
+ range.selectNodeContents(n);
872
+ for (const t of Array.from(range.getClientRects())) {
873
+ if (t.width > 0.5 && t.height > 0.5 && t.right > left + 0.5 && t.left < right - 0.5 && t.bottom > top + 0.5 && t.top < bottom - 0.5)
874
+ return true;
875
+ }
876
+ }
877
+ return false;
878
+ };
879
+ for (const el of els) {
880
+ if (srOnly(el))
881
+ continue;
882
+ // A native listbox scrolls and clips its own options; they are not content cut off by the page.
883
+ if (el.tagName === 'OPTION' || el.tagName === 'OPTGROUP')
884
+ continue;
885
+ const isControl = el.matches(CONTROL);
886
+ const textLeaf = hasOwnText(el);
887
+ if (!isControl && !textLeaf)
888
+ continue;
889
+ const r = el.getBoundingClientRect();
890
+ const { vis, clipper, peek, moving } = clipRectOf(el);
891
+ const full = area(r);
892
+ const seen = area(vis);
893
+ if (clipper && full > 0 && seen < full - 0.5) {
894
+ const hiddenPx = Math.round(Math.max(vis.left - r.left, r.right - vis.right, vis.top - r.top, r.bottom - vis.bottom, 0));
895
+ clips.push({ el: describe(el), box: box(r), visible: { x: vis.left, y: vis.top, w: Math.max(0, vis.right - vis.left), h: Math.max(0, vis.bottom - vis.top) },
896
+ clipper, visibleShare: Math.round((seen / full) * 1000) / 1000, hiddenPx, peek, moving, centreHit: hits(el, r.left + r.width / 2, r.top + r.height / 2) });
897
+ }
898
+ if (!textLeaf || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')
899
+ continue;
900
+ const s = cs(el);
901
+ if (!textInBox(el))
902
+ continue; // the text lies wholly outside its own box: image replacement, hidden on purpose
903
+ const ctl = isControl ? el : el.parentElement?.closest(CONTROL) ?? null;
904
+ const heading = /^H[1-6]$/.test(el.tagName) || el.getAttribute('role') === 'heading' || !!el.closest('label, legend, th');
905
+ const clamp = s.getPropertyValue('-webkit-line-clamp') !== 'none' && s.getPropertyValue('-webkit-line-clamp') !== '';
906
+ const fullText = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
907
+ const titled = !!el.closest('[title]') && (el.closest('[title]').getAttribute('title') ?? '').trim().length >= fullText.length - 1;
908
+ const named = !!ctl && (ctl.getAttribute('aria-label') ?? '').trim().length >= fullText.length - 1;
909
+ const push = (axis, scrollSize, clientSize, ellipsis) => texts.push({
910
+ el: describe(el), ...(ctl ? { control: describe(ctl) } : {}), heading, axis, ellipsis, clamp, scrollSize, clientSize, alternative: titled || named, moving: movingBetween(el, clippingAncestor(el)),
911
+ });
912
+ if ((s.overflowX === 'hidden' || s.overflowX === 'clip') && el.scrollWidth > el.clientWidth + 2)
913
+ push('x', el.scrollWidth, el.clientWidth, s.textOverflow === 'ellipsis');
914
+ else if ((s.overflowY === 'hidden' || s.overflowY === 'clip') && el.scrollHeight > el.clientHeight + 2)
915
+ push('y', el.scrollHeight, el.clientHeight, clamp);
916
+ }
917
+ // ---- fixed and sticky layers ----
918
+ const fixed = [];
919
+ for (const el of Array.from(document.body.querySelectorAll('*'))) {
920
+ if (fixed.length >= 30)
921
+ break;
922
+ const s = cs(el);
923
+ if (s.position !== 'fixed' && s.position !== 'sticky')
924
+ continue;
925
+ if (!shown(el))
926
+ continue;
927
+ let nested = false;
928
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
929
+ const p = cs(a).position;
930
+ if (p === 'fixed' || p === 'sticky') {
931
+ nested = true;
932
+ break;
933
+ }
934
+ }
935
+ if (nested)
936
+ continue;
937
+ const r = el.getBoundingClientRect();
938
+ // A sticky element only behaves like a fixed bar while it is stuck at its offset.
939
+ if (s.position === 'sticky') {
940
+ const top = parseFloat(s.top);
941
+ const bottom = parseFloat(s.bottom);
942
+ const stuck = (!Number.isNaN(top) && Math.abs(r.top - top) <= 1) || (!Number.isNaN(bottom) && Math.abs(vh - r.bottom - bottom) <= 1);
943
+ if (!stuck)
944
+ continue;
945
+ }
946
+ const onVp = inter(r, { left: 0, top: 0, right: vw, bottom: vh });
947
+ const own = Math.max(1, area(r));
948
+ const edge = r.top <= 1 && r.height < vh * 0.5 ? 'top' : r.bottom >= vh - 1 && r.height < vh * 0.5 ? 'bottom' : 'none';
949
+ // Hit-test each control's centre: what a finger lands on there, when it is another fixed layer.
950
+ const coverOf = (c) => {
951
+ const cr = c.getBoundingClientRect();
952
+ const x = cr.left + cr.width / 2;
953
+ const y = cr.top + cr.height / 2;
954
+ if (x < 0 || y < 0 || x >= vw || y >= vh)
955
+ return undefined;
956
+ const h = document.elementFromPoint(x, y);
957
+ if (!h || h === c || c.contains(h) || el.contains(h))
958
+ return undefined;
959
+ for (let n = h; n && n !== document.documentElement; n = n.parentElement) {
960
+ const p = cs(n).position;
961
+ if (p === 'fixed' || p === 'sticky')
962
+ return selectorOf(n);
963
+ }
964
+ return undefined;
965
+ };
966
+ const inner = Array.from(el.querySelectorAll(CONTROL)).filter(shown).slice(0, 12).map((c) => ({ el: describe(c), box: box(c.getBoundingClientRect()), coveredBy: coverOf(c) }));
967
+ if (el.matches(CONTROL))
968
+ inner.unshift({ el: describe(el), box: box(r), coveredBy: coverOf(el) });
969
+ fixed.push({ el: describe(el), box: box(r), position: s.position, edge,
970
+ viewportShare: Math.round((area(onVp) / (vw * vh)) * 1000) / 1000, controls: inner, onScreen: Math.round((area(onVp) / own) * 1000) / 1000 });
971
+ }
972
+ // ---- horizontal scrollers inside content ----
973
+ const scrollers = [];
974
+ for (const el of els) {
975
+ const s = cs(el);
976
+ if (!(s.overflowX === 'auto' || s.overflowX === 'scroll') || el.scrollWidth <= el.clientWidth + 1)
977
+ continue;
978
+ if (el === document.documentElement || el === document.body)
979
+ continue;
980
+ if (el.tagName === 'SELECT')
981
+ continue; // a native listbox scrolls its own options
982
+ const role = el.getAttribute('role') ?? '';
983
+ const reasons = [
984
+ ['tablist', 'listbox', 'grid', 'table', 'toolbar', 'menubar', 'tree', 'region'].includes(role) ? `role=${role}` : '',
985
+ /carousel|slider|slideshow/i.test(`${el.getAttribute('aria-roledescription') ?? ''} ${el.className}`) ? 'carousel' : '',
986
+ s.scrollSnapType && s.scrollSnapType !== 'none' ? 'scroll-snap' : '',
987
+ el.querySelector(':scope > table, :scope > pre, :scope > code, :scope > * > table') || ['PRE', 'CODE', 'TABLE'].includes(el.tagName) ? 'table or code' : '',
988
+ ];
989
+ const kids = Array.from(el.children).filter(shown);
990
+ const row = kids.length >= 3 && kids.every((k, i) => i === 0 || k.getBoundingClientRect().left > kids[i - 1].getBoundingClientRect().left);
991
+ if (row)
992
+ reasons.push('a row of items');
993
+ const intentional = reasons.filter(Boolean).join(', ') || undefined;
994
+ scrollers.push({ el: describe(el), box: box(el.getBoundingClientRect()), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth,
995
+ ...(intentional ? { intentional } : {}), paragraphs: el.querySelectorAll('p').length });
996
+ }
997
+ // ---- controls sticking out of their visible container ----
998
+ const outside = [];
999
+ const looksLikeBox = (a) => {
1000
+ const s = cs(a);
1001
+ // A box a person sees as one: a background, a shadow, or borders on at least three sides (a
1002
+ // divider line along one edge does not make a container a control can "leave").
1003
+ const border = ['top', 'right', 'bottom', 'left'].filter((side) => parseFloat(s.getPropertyValue(`border-${side}-width`)) > 0 && !/rgba\(.*, 0\)|transparent/.test(s.getPropertyValue(`border-${side}-color`))).length >= 3;
1004
+ const bg = !/rgba\(0, 0, 0, 0\)|transparent/.test(s.backgroundColor);
1005
+ return border || bg || s.boxShadow !== 'none';
1006
+ };
1007
+ for (const el of controls) {
1008
+ const s = cs(el);
1009
+ if (s.position === 'absolute' || s.position === 'fixed')
1010
+ continue; // corner badges and floating buttons are placed on purpose
1011
+ let container = null;
1012
+ // A scrolling or clipping element in between (a scrolled list inside a panel) hides whatever passes
1013
+ // the panel's edge, so nothing a person sees sticks out.
1014
+ let clippedBetween = false;
1015
+ for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
1016
+ if (looksLikeBox(a)) {
1017
+ container = a;
1018
+ break;
1019
+ }
1020
+ const as = cs(a);
1021
+ if (as.overflowX !== 'visible' || as.overflowY !== 'visible') {
1022
+ clippedBetween = true;
1023
+ break;
1024
+ }
1025
+ }
1026
+ if (!container || clippedBetween)
1027
+ continue;
1028
+ const cst = cs(container);
1029
+ if (cst.overflowX !== 'visible' || cst.overflowY !== 'visible')
1030
+ continue; // clipping is reported as container-clipped
1031
+ const r = el.getBoundingClientRect();
1032
+ const c = container.getBoundingClientRect();
1033
+ if (c.width >= vw - 1 && c.left <= 0)
1034
+ continue; // a full-width band is not a container a control can leave
1035
+ const overhang = { top: Math.round(c.top - r.top), right: Math.round(r.right - c.right), bottom: Math.round(r.bottom - c.bottom), left: Math.round(c.left - r.left) };
1036
+ if (Math.max(overhang.top, overhang.right, overhang.bottom, overhang.left) >= 4) {
1037
+ outside.push({ el: describe(el), box: box(r), container: selectorOf(container), containerBox: box(c), overhang });
1038
+ }
1039
+ }
1040
+ // ---- content before a scroll origin (left of or above it), which no scrolling can reveal ----
1041
+ const beforeOrigin = [];
1042
+ const scrollBoxes = [document.documentElement, ...els.filter((e) => { const s = cs(e); return ['auto', 'scroll'].includes(s.overflowX) || ['auto', 'scroll'].includes(s.overflowY); })];
1043
+ for (const el of els) {
1044
+ if (srOnly(el) || !(el.matches(CONTROL) || hasOwnText(el)))
1045
+ continue;
1046
+ if (cs(el).position === 'fixed')
1047
+ continue;
1048
+ const r = el.getBoundingClientRect();
1049
+ // The nearest scroll container (or the document) and its origin in viewport coordinates.
1050
+ let sc = document.documentElement;
1051
+ for (let a = el.parentElement; a; a = a.parentElement)
1052
+ if (scrollBoxes.includes(a) && a !== document.documentElement) {
1053
+ sc = a;
1054
+ break;
1055
+ }
1056
+ let originX;
1057
+ let originY;
1058
+ if (sc === document.documentElement) {
1059
+ originX = -window.scrollX;
1060
+ originY = -window.scrollY;
1061
+ }
1062
+ else {
1063
+ const b = sc.getBoundingClientRect();
1064
+ originX = b.left + sc.clientLeft - sc.scrollLeft;
1065
+ originY = b.top + sc.clientTop - sc.scrollTop;
1066
+ }
1067
+ const hiddenX = Math.max(0, originX - r.left);
1068
+ const hiddenY = Math.max(0, originY - r.top);
1069
+ if (hiddenX < 2 && hiddenY < 2)
1070
+ continue;
1071
+ const share = 1 - Math.max(0, r.width - hiddenX) * Math.max(0, r.height - hiddenY) / Math.max(1, r.width * r.height);
1072
+ if (share >= 0.999)
1073
+ continue; // entirely before the origin: an off-canvas panel, not cut content
1074
+ beforeOrigin.push({ el: describe(el), box: box(r), container: sc === document.documentElement ? 'the page' : selectorOf(sc),
1075
+ hiddenPx: Math.round(Math.max(hiddenX, hiddenY)), share: Math.round(share * 1000) / 1000,
1076
+ centreHidden: r.left + r.width / 2 < originX || r.top + r.height / 2 < originY, moving: movingBetween(el, sc) });
1077
+ }
1078
+ // ---- control label lines, for comparing widths ----
1079
+ const labels = [];
1080
+ // What is actually visible of a box: cut by every ancestor that does not let overflow show
1081
+ // (scrolled content under a drawer's footer does not overlap the footer).
1082
+ const visibleBox = (el) => {
1083
+ let v = el.getBoundingClientRect();
1084
+ for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
1085
+ const st = cs(a);
1086
+ if (st.overflowX === 'visible' && st.overflowY === 'visible')
1087
+ continue;
1088
+ const b = a.getBoundingClientRect();
1089
+ v = { left: st.overflowX !== 'visible' ? Math.max(v.left, b.left) : v.left, right: st.overflowX !== 'visible' ? Math.min(v.right, b.right) : v.right,
1090
+ top: st.overflowY !== 'visible' ? Math.max(v.top, b.top) : v.top, bottom: st.overflowY !== 'visible' ? Math.min(v.bottom, b.bottom) : v.bottom };
1091
+ }
1092
+ return v;
1093
+ };
1094
+ const ctlBoxes = controls.map((c) => ({ c, r: c.getBoundingClientRect(), v: visibleBox(c) }));
1095
+ for (const { c, r, v } of ctlBoxes) {
1096
+ if (labels.length >= 80)
1097
+ break;
1098
+ const range = document.createRange();
1099
+ const tops = new Set();
1100
+ const walker = document.createTreeWalker(c, NodeFilter.SHOW_TEXT);
1101
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
1102
+ if (!(t.textContent ?? '').trim())
1103
+ continue;
1104
+ range.selectNodeContents(t);
1105
+ for (const lr of Array.from(range.getClientRects()))
1106
+ if (lr.width > 1)
1107
+ tops.add(Math.round(lr.top));
1108
+ }
1109
+ // Rects of one line can differ by a pixel or two (inline boxes); merge tops closer than 4 px.
1110
+ const lines = [...tops].sort((a, b) => a - b).filter((t, i, a) => i === 0 || t - a[i - 1] >= 4).length;
1111
+ if (!lines)
1112
+ continue;
1113
+ const row = ctlBoxes.filter((o) => o.c !== c && o.c.parentElement === c.parentElement &&
1114
+ Math.min(o.r.bottom, r.bottom) - Math.max(o.r.top, r.top) > Math.min(o.r.height, r.height) * 0.5);
1115
+ const overlap = ctlBoxes.find((o) => o.c !== c && !o.c.contains(c) && !c.contains(o.c) && area(inter(o.v, v)) > 16);
1116
+ labels.push({ el: describe(c), lines, box: box(r), rowCount: row.length,
1117
+ ...(row.length ? { rowMinH: Math.min(...row.map((o) => o.r.height)), rowMaxH: Math.max(...row.map((o) => o.r.height)) } : {}),
1118
+ ...(overlap ? { overlaps: `${roleOf(overlap.c)} "${nameOf(overlap.c)}"` } : {}) });
1119
+ }
1120
+ let modal;
1121
+ if (modalEl) {
1122
+ const r = modalEl.getBoundingClientRect();
1123
+ const inner = [modalEl, ...Array.from(modalEl.querySelectorAll('*'))].some((n) => {
1124
+ const s = cs(n);
1125
+ return ['auto', 'scroll'].includes(s.overflowY) && n.scrollHeight > n.clientHeight + 1;
1126
+ });
1127
+ modal = { el: describe(modalEl), box: box(r), scrollable: inner, overflowPx: Math.round(Math.max(0, r.bottom - vh, r.right - vw, -r.top, -r.left)),
1128
+ controls: modalEl.querySelectorAll(CONTROL).length };
1129
+ }
1130
+ const se = document.scrollingElement ?? document.documentElement;
1131
+ return {
1132
+ viewport: { width: vw, height: Math.round(vh) },
1133
+ scroll: { x: Math.round(window.scrollX), y: Math.round(window.scrollY) },
1134
+ scrollMax: { x: Math.max(0, se.scrollWidth - se.clientWidth), y: Math.max(0, se.scrollHeight - se.clientHeight) },
1135
+ ...(modal ? { modal } : {}),
1136
+ targets: targets.slice(0, 400), clips: cap(clips), texts: cap(texts), fixed, scrollers: cap(scrollers), outside: cap(outside),
1137
+ beforeOrigin: cap(beforeOrigin), labels, examined: els.length, omitted,
1138
+ };
1139
+ }
1140
+ /**
1141
+ * In-page: at the page's scroll extremes (top, then bottom), which content sits under a fixed or sticky
1142
+ * bar there. No scroll position can uncover it, because the page cannot scroll any further.
1143
+ */
1144
+ export function coveredAtExtremes(args) {
1145
+ const CONTROL = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=switch],[role=tab],[role=menuitem],[tabindex]:not([tabindex="-1"])';
1146
+ const reg = window['__agentDeviceLab_v1'];
1147
+ const clean = (s) => (s ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
1148
+ const vw = document.documentElement.clientWidth;
1149
+ const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
1150
+ const out = [];
1151
+ const isFixed = (el) => { for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
1152
+ const p = getComputedStyle(n).position;
1153
+ if (p === 'fixed' || p === 'sticky')
1154
+ return n;
1155
+ } return null; };
1156
+ const sel = (el) => `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ''}`;
1157
+ const describe = (el) => {
1158
+ const control = el.matches(CONTROL);
1159
+ const ref = reg?.byEl.get(el);
1160
+ const name = clean(el.getAttribute('aria-label')) || clean(el.innerText ?? el.textContent);
1161
+ return { role: control ? (el.getAttribute('role') ?? (el.tagName === 'A' ? 'link' : 'button')) : /^H[1-6]$/.test(el.tagName) ? 'heading' : 'text', name, selector: sel(el), control, ...(ref ? { ref } : {}) };
1162
+ };
1163
+ const se = document.scrollingElement ?? document.documentElement;
1164
+ if (getComputedStyle(document.body).overflowY === 'hidden' || getComputedStyle(document.documentElement).overflowY === 'hidden')
1165
+ return out; // scroll locked (a modal is open)
1166
+ for (const edge of ['top', 'bottom']) {
1167
+ window.scrollTo({ left: 0, top: edge === 'top' ? 0 : se.scrollHeight, behavior: 'instant' });
1168
+ const bars = Array.from(document.body.querySelectorAll('*')).filter((el) => {
1169
+ const p = getComputedStyle(el).position;
1170
+ if (p !== 'fixed' && p !== 'sticky')
1171
+ return false;
1172
+ const r = el.getBoundingClientRect();
1173
+ if (r.width < vw * 0.5 || r.height < 8 || r.height > vh * 0.4)
1174
+ return false;
1175
+ return edge === 'top' ? r.top <= 1 && r.bottom > 0 : r.bottom >= vh - 1 && r.top < vh;
1176
+ });
1177
+ for (const bar of bars) {
1178
+ const b = bar.getBoundingClientRect();
1179
+ for (const el of Array.from(document.body.querySelectorAll('*'))) {
1180
+ if (out.length >= args.maxItems)
1181
+ break;
1182
+ if (bar.contains(el) || isFixed(el))
1183
+ continue;
1184
+ const own = Array.from(el.childNodes).some((t) => t.nodeType === Node.TEXT_NODE && (t.textContent ?? '').trim());
1185
+ if (!el.matches(CONTROL) && !own)
1186
+ continue;
1187
+ if (el.parentElement?.closest(CONTROL))
1188
+ continue;
1189
+ const r = el.getBoundingClientRect();
1190
+ if (r.width < 1 || r.height < 1 || !el.checkVisibility({ opacityProperty: true, visibilityProperty: true }))
1191
+ continue;
1192
+ const ix = Math.max(0, Math.min(r.right, b.right) - Math.max(r.left, b.left));
1193
+ const iy = Math.max(0, Math.min(r.bottom, b.bottom) - Math.max(r.top, b.top));
1194
+ const share = (ix * iy) / Math.max(1, r.width * r.height);
1195
+ if (share < 0.2)
1196
+ continue;
1197
+ const cx = r.left + r.width / 2;
1198
+ const cy = r.top + r.height / 2;
1199
+ const hit = cx >= 0 && cy >= 0 && cx < vw && cy < vh ? document.elementFromPoint(cx, cy) : null;
1200
+ const centreCovered = !!hit && !(hit === el || el.contains(hit));
1201
+ if (out.some((o) => o.el.selector === sel(el) && o.el.name === describe(el).name))
1202
+ continue;
1203
+ out.push({ el: describe(el), bar: sel(bar), edge, share: Math.round(share * 1000) / 1000, centreCovered,
1204
+ box: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } });
1205
+ }
1206
+ }
1207
+ }
1208
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
1209
+ return out;
1210
+ }
1211
+ /** Init script: buffer layout shifts (the browser's own Layout Instability API) for takeLayoutShifts. */
1212
+ export function installShiftObserver() {
1213
+ const key = '__agentDeviceLab_shifts';
1214
+ if (window[key])
1215
+ return;
1216
+ const buffer = [];
1217
+ Object.defineProperty(window, key, { value: buffer, enumerable: false });
1218
+ try {
1219
+ new PerformanceObserver((list) => {
1220
+ for (const e of list.getEntries()) {
1221
+ if (buffer.length >= 200)
1222
+ buffer.shift();
1223
+ buffer.push({
1224
+ value: e.value, recentInput: !!e.hadRecentInput, at: Math.round(e.startTime),
1225
+ sources: (e.sources ?? []).slice(0, 5).map((s) => {
1226
+ const n = s.node;
1227
+ const el = n && n.nodeType === 1 ? n : n?.parentElement ?? null;
1228
+ const r = (x) => [Math.round(x.x), Math.round(x.y), Math.round(x.width), Math.round(x.height)];
1229
+ return {
1230
+ selector: el ? `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/)[0]}` : ''}` : 'text',
1231
+ name: el ? ((el.getAttribute('aria-label') || el.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 40)) : '',
1232
+ from: r(s.previousRect), to: r(s.currentRect),
1233
+ };
1234
+ }),
1235
+ });
1236
+ }
1237
+ }).observe({ type: 'layout-shift', buffered: true });
1238
+ }
1239
+ catch { /* the API is unavailable: no shifts are reported */ }
1240
+ }
1241
+ /** In-page: layout shifts since the last call (the buffer is emptied). */
1242
+ export function takeLayoutShifts() {
1243
+ const buffer = window.__agentDeviceLab_shifts;
1244
+ if (!buffer)
1245
+ return [];
1246
+ return buffer.splice(0, buffer.length);
1247
+ }
1248
+ /** In-page: exploration traits for the given refs (from the session registry). */
1249
+ export function controlTraits(refs) {
1250
+ const reg = window['__agentDeviceLab_v1'];
1251
+ const out = [];
1252
+ for (const ref of refs) {
1253
+ const el = reg?.byRef.get(ref)?.deref();
1254
+ if (!el || !el.isConnected)
1255
+ continue;
1256
+ const tag = el.tagName.toLowerCase();
1257
+ const t = { ref, tag, role: el.getAttribute('role') ?? '' };
1258
+ const attr = (n) => el.getAttribute(n) ?? undefined;
1259
+ const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
1260
+ t[k] = v; };
1261
+ set('haspopup', attr('aria-haspopup'));
1262
+ set('expanded', attr('aria-expanded'));
1263
+ set('selected', attr('aria-selected'));
1264
+ set('pressed', attr('aria-pressed'));
1265
+ const controls = attr('aria-controls');
1266
+ if (controls) {
1267
+ const target = document.getElementById(controls.split(/\s+/)[0]);
1268
+ if (target && (target.hidden || !target.checkVisibility()))
1269
+ t.controlsHidden = true;
1270
+ }
1271
+ if (tag === 'input') {
1272
+ const type = el.type;
1273
+ t.type = type;
1274
+ if (type === 'submit' || type === 'image')
1275
+ t.submits = true;
1276
+ if (type === 'reset')
1277
+ t.resets = true;
1278
+ if (type === 'file')
1279
+ t.file = true;
1280
+ }
1281
+ if (tag === 'button') {
1282
+ const type = el.getAttribute('type')?.toLowerCase();
1283
+ t.type = type ?? 'submit';
1284
+ const form = el.form;
1285
+ if (form && (!type || type === 'submit')) {
1286
+ t.submits = true;
1287
+ set('formName', form.getAttribute('aria-label') ?? form.getAttribute('name') ?? form.id);
1288
+ }
1289
+ if (type === 'reset')
1290
+ t.resets = true;
1291
+ }
1292
+ if (tag === 'label' && el.control instanceof HTMLInputElement && el.control.type === 'file')
1293
+ t.file = true;
1294
+ if (tag === 'a' && el.hasAttribute('href')) {
1295
+ const raw = el.getAttribute('href') ?? '';
1296
+ const u = new URL(raw, location.href);
1297
+ t.link = raw.startsWith('#') || (u.origin === location.origin && u.pathname === location.pathname && u.search === location.search) ? 'same-page'
1298
+ : u.origin === location.origin ? 'same-origin' : 'external';
1299
+ if (el.getAttribute('target') === '_blank')
1300
+ t.newTab = true;
1301
+ if (el.hasAttribute('download'))
1302
+ t.download = true;
1303
+ }
1304
+ if (tag === 'summary') {
1305
+ const d = el.parentElement;
1306
+ if (d instanceof HTMLDetailsElement && !d.open)
1307
+ t.closedDetails = true;
1308
+ }
1309
+ out.push(t);
1310
+ }
1311
+ return out;
1312
+ }
1313
+ /**
1314
+ * Init script: report what a person does in the page (taps, typing, keys, choices, submits) through the
1315
+ * `__agentDeviceLabHuman` binding, at a privacy-safe level: the control's role and a short name, never
1316
+ * typed text; for password-like fields not even the length. The Lab keeps a report only while the
1317
+ * session is paused or under a person's control, so the agent's own input is never recorded as theirs.
1318
+ */
1319
+ export function installHumanRecorder() {
1320
+ const w = window;
1321
+ if (w.__agentDeviceLabHumanOn)
1322
+ return;
1323
+ w.__agentDeviceLabHumanOn = true;
1324
+ const send = (a) => {
1325
+ try {
1326
+ void Promise.resolve(w.__agentDeviceLabHuman?.(a)).catch(() => undefined);
1327
+ }
1328
+ catch { /* binding not ready */ }
1329
+ };
1330
+ const clip = (s) => (s ?? '').replace(/\s+/g, ' ').trim().slice(0, 60);
1331
+ const SECRET_AUTOCOMPLETE = /(password|one-time-code|cc-number|cc-csc)/i;
1332
+ const SECRET_NAME = /(pass|secret|token|otp|pin\b|cvv|cvc|iban|ssn)/i;
1333
+ const secret = (el) => el instanceof HTMLInputElement &&
1334
+ (el.type === 'password' || SECRET_AUTOCOMPLETE.test(el.autocomplete || '') || SECRET_NAME.test(`${el.name} ${el.id}`));
1335
+ const role = (el) => {
1336
+ const explicit = el.getAttribute('role');
1337
+ if (explicit)
1338
+ return explicit.split(/\s+/)[0];
1339
+ const tag = el.tagName.toLowerCase();
1340
+ if (tag === 'a')
1341
+ return 'link';
1342
+ if (tag === 'button' || tag === 'summary')
1343
+ return 'button';
1344
+ if (tag === 'select')
1345
+ return 'combobox';
1346
+ if (tag === 'textarea')
1347
+ return 'textbox';
1348
+ if (tag === 'input') {
1349
+ const t = el.type;
1350
+ if (t === 'checkbox' || t === 'radio' || t === 'range')
1351
+ return t === 'range' ? 'slider' : t;
1352
+ if (t === 'button' || t === 'submit' || t === 'reset' || t === 'image')
1353
+ return 'button';
1354
+ if (t === 'file')
1355
+ return 'file';
1356
+ return 'textbox';
1357
+ }
1358
+ if (el.isContentEditable)
1359
+ return 'textbox';
1360
+ return tag;
1361
+ };
1362
+ const name = (el) => {
1363
+ const aria = el.getAttribute('aria-label');
1364
+ if (aria)
1365
+ return clip(aria);
1366
+ const by = el.getAttribute('aria-labelledby');
1367
+ if (by) {
1368
+ const text = by.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? '').join(' ');
1369
+ if (clip(text))
1370
+ return clip(text);
1371
+ }
1372
+ const labels = el.labels;
1373
+ if (labels && labels.length)
1374
+ return clip(labels[0].textContent);
1375
+ const r = role(el);
1376
+ if (r === 'button' || r === 'link' || r === 'tab' || r === 'menuitem' || r === 'option') {
1377
+ const text = clip(el.innerText || el.textContent);
1378
+ if (text)
1379
+ return text;
1380
+ }
1381
+ return clip(el.getAttribute('title') || el.getAttribute('placeholder') || el.getAttribute('alt') || el.value && el.type === 'submit' && el.value || '');
1382
+ };
1383
+ const target = (el) => ({ role: role(el), name: name(el) });
1384
+ const INTERACTIVE = 'a[href],button,input,select,textarea,summary,[contenteditable=""],[contenteditable="true"],[role=button],[role=link],[role=tab],[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio],[role=checkbox],[role=radio],[role=switch],[role=option],[role=combobox],[role=textbox],[tabindex]';
1385
+ const interactive = (t) => (t instanceof Element ? t.closest(INTERACTIVE) : null);
1386
+ const textLike = (el) => el instanceof HTMLTextAreaElement || el.isContentEditable ||
1387
+ (el instanceof HTMLInputElement && !['checkbox', 'radio', 'file', 'range', 'button', 'submit', 'reset', 'image', 'color'].includes(el.type));
1388
+ document.addEventListener('click', (e) => {
1389
+ if (!e.isTrusted)
1390
+ return;
1391
+ const el = interactive(e.target);
1392
+ // Checkboxes, radios, selects and file inputs are reported by their change; text fields by typing.
1393
+ if (!el || el instanceof HTMLSelectElement || (el instanceof HTMLInputElement && (textLike(el) || ['checkbox', 'radio', 'file'].includes(el.type))))
1394
+ return;
1395
+ send({ type: 'tap', target: target(el) });
1396
+ }, true);
1397
+ // Typing is described once it pauses (700 ms), and at once when the person leaves the field, presses
1398
+ // Enter or Tab, leaves the page, or control is handed back (the Lab calls the flush below).
1399
+ const pending = new Map();
1400
+ const flushTyping = (el, requested = false) => {
1401
+ clearTimeout(pending.get(el));
1402
+ pending.delete(el);
1403
+ const s = secret(el);
1404
+ const length = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value.length : (el.textContent ?? '').length;
1405
+ send({ type: 'type', target: target(el), ...(s ? { secret: true } : { chars: length }), ...(requested ? { flushed: true } : {}) });
1406
+ };
1407
+ const flushAll = (requested) => { for (const el of [...pending.keys()])
1408
+ flushTyping(el, requested); };
1409
+ w.__agentDeviceLabHumanFlush = () => flushAll(true);
1410
+ document.addEventListener('input', (e) => {
1411
+ if (!e.isTrusted || !(e.target instanceof Element) || !textLike(e.target))
1412
+ return;
1413
+ const el = e.target;
1414
+ clearTimeout(pending.get(el));
1415
+ pending.set(el, setTimeout(() => flushTyping(el), 700));
1416
+ }, true);
1417
+ document.addEventListener('focusout', (e) => {
1418
+ if (e.isTrusted && e.target instanceof Element && pending.has(e.target))
1419
+ flushTyping(e.target);
1420
+ }, true);
1421
+ window.addEventListener('pagehide', () => flushAll(false));
1422
+ document.addEventListener('change', (e) => {
1423
+ if (!e.isTrusted || !(e.target instanceof Element))
1424
+ return;
1425
+ const el = e.target;
1426
+ if (el instanceof HTMLSelectElement)
1427
+ send({ type: 'select', target: target(el), detail: `${el.selectedOptions.length} option(s) selected` });
1428
+ else if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio'))
1429
+ send({ type: 'check', target: target(el), detail: el.checked ? 'checked' : 'unchecked' });
1430
+ else if (el instanceof HTMLInputElement && el.type === 'file')
1431
+ send({ type: 'upload', target: target(el), detail: `${el.files?.length ?? 0} file(s) chosen` });
1432
+ }, true);
1433
+ document.addEventListener('keydown', (e) => {
1434
+ if (!e.isTrusted || !['Enter', 'Escape', 'Tab'].includes(e.key))
1435
+ return;
1436
+ if ((e.key === 'Enter' || e.key === 'Tab') && e.target instanceof Element && pending.has(e.target))
1437
+ flushTyping(e.target); // what was typed comes before the key
1438
+ send({ type: 'key', detail: `${e.shiftKey ? 'Shift+' : ''}${e.key}`, ...(e.target instanceof Element && e.target !== document.body ? { target: target(e.target) } : {}) });
1439
+ }, true);
1440
+ document.addEventListener('submit', (e) => {
1441
+ if (e.target instanceof HTMLFormElement)
1442
+ send({ type: 'submit', target: { role: 'form', name: clip(e.target.getAttribute('aria-label') || e.target.getAttribute('name') || e.target.id) } });
1443
+ }, true);
1444
+ }
1445
+ //# sourceMappingURL=extract.js.map