@mlola/backend-playwright 0.1.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.
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Atomic in-page observation (PRD §12).
3
+ *
4
+ * One page-side pass collects URL, title, visible text, controls, roles,
5
+ * labels, values, state, options, geometry, hit-testing, blocking UI, scroll
6
+ * and structural fingerprint — instead of a protocol conversation per element.
7
+ *
8
+ * The function below is serialized by Playwright and runs in the page, so it
9
+ * must be entirely self-contained.
10
+ */
11
+ /** Serialized and executed inside the page. Must stay self-contained. */
12
+ export function collectSnapshot(args) {
13
+ /* eslint-disable @typescript-eslint/no-explicit-any */
14
+ const w = window;
15
+ const BRIDGE_KEY = "__mlola_bridge_v1";
16
+ if (!w[BRIDGE_KEY]) {
17
+ const bridge = {
18
+ ids: new WeakMap(),
19
+ byId: new Map(),
20
+ nextId: 1,
21
+ mutations: 0,
22
+ };
23
+ Object.defineProperty(w, BRIDGE_KEY, { value: bridge, enumerable: false, configurable: true });
24
+ try {
25
+ const observer = new MutationObserver((records) => {
26
+ bridge.mutations += records.length;
27
+ });
28
+ observer.observe(document.documentElement, {
29
+ childList: true,
30
+ subtree: true,
31
+ attributes: true,
32
+ characterData: false,
33
+ });
34
+ }
35
+ catch {
36
+ /* observation is best-effort */
37
+ }
38
+ }
39
+ const bridge = w[BRIDGE_KEY];
40
+ const nodeIdOf = (el) => {
41
+ let id = bridge.ids.get(el);
42
+ if (typeof id !== "number") {
43
+ id = bridge.nextId++;
44
+ bridge.ids.set(el, id);
45
+ try {
46
+ bridge.byId.set(id, new WeakRef(el));
47
+ }
48
+ catch {
49
+ bridge.byId.set(id, el);
50
+ }
51
+ }
52
+ return id;
53
+ };
54
+ const hash = (input) => {
55
+ let h = 2166136261;
56
+ for (let i = 0; i < input.length; i += 1) {
57
+ h ^= input.charCodeAt(i);
58
+ h = Math.imul(h, 16777619);
59
+ }
60
+ return (h >>> 0).toString(16).padStart(8, "0");
61
+ };
62
+ const collapse = (s) => s.replace(/\s+/g, " ").trim();
63
+ const isVisible = (el) => {
64
+ const style = getComputedStyle(el);
65
+ if (style.visibility === "hidden" || style.display === "none" || style.opacity === "0")
66
+ return false;
67
+ if (el.getClientRects().length === 0)
68
+ return false;
69
+ if (el.closest('[aria-hidden="true"]') && !el.closest('[role="dialog"], [role="alertdialog"]'))
70
+ return false;
71
+ return true;
72
+ };
73
+ const rectOf = (el) => {
74
+ const r = el.getBoundingClientRect();
75
+ return { x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) };
76
+ };
77
+ const docRectOf = (el) => {
78
+ const r = el.getBoundingClientRect();
79
+ return {
80
+ x: Math.round(r.left + scrollX),
81
+ y: Math.round(r.top + scrollY),
82
+ width: Math.round(r.width),
83
+ height: Math.round(r.height),
84
+ };
85
+ };
86
+ const hitTarget = (el, r) => {
87
+ if (r.width < 2 || r.height < 2)
88
+ return false;
89
+ const x = Math.min(Math.max(r.x + r.width / 2, 1), innerWidth - 2);
90
+ const y = Math.min(Math.max(r.y + r.height / 2, 1), innerHeight - 2);
91
+ const top = document.elementFromPoint(x, y);
92
+ if (!top)
93
+ return false;
94
+ return top === el || el.contains(top) || top.contains(el);
95
+ };
96
+ const nameOf = (el) => {
97
+ const aria = el.getAttribute("aria-label");
98
+ if (aria && collapse(aria).length > 0)
99
+ return collapse(aria);
100
+ const labelledBy = el.getAttribute("aria-labelledby");
101
+ if (labelledBy) {
102
+ const parts = labelledBy
103
+ .split(/\s+/)
104
+ .map((id) => document.getElementById(id))
105
+ .filter((n) => n !== null)
106
+ .map((n) => collapse(n.innerText || n.textContent || ""));
107
+ const joined = collapse(parts.join(" "));
108
+ if (joined.length > 0)
109
+ return joined;
110
+ }
111
+ const id = el.getAttribute("id");
112
+ if (id) {
113
+ const explicit = document.querySelector(`label[for="${CSS.escape(id)}"]`);
114
+ if (explicit) {
115
+ const text = collapse(explicit.innerText || explicit.textContent || "");
116
+ if (text.length > 0)
117
+ return text;
118
+ }
119
+ }
120
+ const ancestorLabel = el.closest("label");
121
+ if (ancestorLabel) {
122
+ const text = collapse(ancestorLabel.innerText || ancestorLabel.textContent || "");
123
+ const own = el.getAttribute("placeholder") ?? "";
124
+ const cleaned = collapse(text.replace(own, ""));
125
+ if (cleaned.length > 0 && cleaned.length < 120)
126
+ return cleaned;
127
+ }
128
+ const tag = el.tagName.toLowerCase();
129
+ const type = (el.getAttribute("type") ?? "").toLowerCase();
130
+ if (tag === "input" && (type === "submit" || type === "button" || type === "reset")) {
131
+ const v = el.value;
132
+ if (v && v.length > 0)
133
+ return collapse(v);
134
+ }
135
+ if (tag === "img") {
136
+ const alt = el.getAttribute("alt");
137
+ if (alt)
138
+ return collapse(alt);
139
+ }
140
+ const title = el.getAttribute("title");
141
+ if (title && collapse(title).length > 0)
142
+ return collapse(title);
143
+ const placeholder = el.getAttribute("placeholder");
144
+ if (placeholder && collapse(placeholder).length > 0)
145
+ return collapse(placeholder);
146
+ const text = collapse(el.innerText || el.textContent || "");
147
+ if (text.length > 0)
148
+ return text.slice(0, 140);
149
+ if (tag === "a") {
150
+ const href = el.getAttribute("href") ?? "";
151
+ if (href.length > 0 && href !== "#")
152
+ return href.slice(0, 100);
153
+ }
154
+ return "";
155
+ };
156
+ const roleOf = (el) => {
157
+ const explicit = (el.getAttribute("role") ?? "").toLowerCase();
158
+ if (explicit.length > 0) {
159
+ const known = [
160
+ "button",
161
+ "link",
162
+ "textbox",
163
+ "searchbox",
164
+ "combobox",
165
+ "listbox",
166
+ "option",
167
+ "checkbox",
168
+ "radio",
169
+ "switch",
170
+ "slider",
171
+ "spinbutton",
172
+ "menuitem",
173
+ "menuitemcheckbox",
174
+ "menuitemradio",
175
+ "tab",
176
+ "dialog",
177
+ "alertdialog",
178
+ "alert",
179
+ "heading",
180
+ ];
181
+ if (known.indexOf(explicit) >= 0)
182
+ return explicit;
183
+ }
184
+ const tag = el.tagName.toLowerCase();
185
+ if (tag === "a")
186
+ return "link";
187
+ if (tag === "button")
188
+ return "button";
189
+ if (tag === "select")
190
+ return el.multiple ? "listbox" : "combobox";
191
+ if (tag === "textarea")
192
+ return "textbox";
193
+ if (tag === "dialog")
194
+ return "dialog";
195
+ if (el.hasAttribute("contenteditable") && el.getAttribute("contenteditable") !== "false")
196
+ return "contenteditable";
197
+ if (tag === "input") {
198
+ const type = (el.getAttribute("type") ?? "text").toLowerCase();
199
+ if (type === "checkbox")
200
+ return "checkbox";
201
+ if (type === "radio")
202
+ return "radio";
203
+ if (type === "file")
204
+ return "fileinput";
205
+ if (type === "search")
206
+ return "searchbox";
207
+ if (type === "submit" || type === "button" || type === "reset" || type === "image")
208
+ return "button";
209
+ if (type === "range")
210
+ return "slider";
211
+ if (type === "number")
212
+ return "spinbutton";
213
+ if (type === "hidden")
214
+ return "other";
215
+ return "textbox";
216
+ }
217
+ return "other";
218
+ };
219
+ const DOWNLOAD_EXT = /\.(pdf|csv|xlsx|xls|docx|doc|zip|png|jpe?g|txt|json|xml|epub|srt)$/i;
220
+ const DOWNLOAD_LABEL = /\b(download|unduh|export|save\s+as|simpan|ekspor|\(pdf\)|\(csv\))\b/i;
221
+ const UPLOAD_LABEL = /\b(upload|unggah|choose\s+file|select\s+file|add\s+file|attach|lampirkan|pilih\s+file)\b/i;
222
+ const selector = [
223
+ "a[href]",
224
+ "button",
225
+ "input:not([type=hidden])",
226
+ "select",
227
+ "textarea",
228
+ "summary",
229
+ "[role=button]",
230
+ "[role=link]",
231
+ "[role=textbox]",
232
+ "[role=searchbox]",
233
+ "[role=combobox]",
234
+ "[role=listbox]",
235
+ "[role=checkbox]",
236
+ "[role=radio]",
237
+ "[role=switch]",
238
+ "[role=tab]",
239
+ "[role=menuitem]",
240
+ "[role=menuitemcheckbox]",
241
+ "[role=menuitemradio]",
242
+ "[role=slider]",
243
+ "[role=spinbutton]",
244
+ "[role=option]",
245
+ "[contenteditable=true]",
246
+ "[contenteditable='']",
247
+ "[tabindex]",
248
+ "[ondrop]",
249
+ "[onclick]",
250
+ "label[for]",
251
+ ].join(",");
252
+ const all = Array.from(document.querySelectorAll(selector));
253
+ const seen = new Set();
254
+ const nodes = [];
255
+ const elements = [];
256
+ const nodeIndexByElement = new Map();
257
+ /**
258
+ * Agent-directed instruction text next to a control (PRD §22.4). Deterministic
259
+ * and structural: preceding sibling text within three ancestor levels.
260
+ */
261
+ const AGENT_DIRECTED = [
262
+ /\bignore\s+(all\s+|any\s+|the\s+)?(previous|prior|above|earlier)\b/i,
263
+ /<\s*\/?\s*(system|assistant)\s*>/i,
264
+ /\b(?:hey|dear)\s+(ai|agent|assistant|bot|claude|gpt)\b/i,
265
+ /\b(ai|agent|assistant|bot)[^.]{0,30}\b(click|press|select|open|go\s+to|navigate|send|upload|pay)\b/i,
266
+ /\b(do\s+not|don'?t|never)\s+(tell|inform|notify|mention|show)\b/i,
267
+ /\b(send|share|paste|upload|email|forward)\b[^.]{0,40}\b(password|passcode|api[\s_-]?key|secret|token|otp|credentials?)\b/i,
268
+ /\bimportant\s*:?\s*(agent|ai|assistant)\b/i,
269
+ /\bnew\s+instructions?\s*:/i,
270
+ ];
271
+ const nearbyInstruction = (el) => {
272
+ // Only the element's own preceding siblings count: instruction text in a
273
+ // different container must not quarantine an unrelated control.
274
+ let sibling = el.previousElementSibling;
275
+ let hops = 0;
276
+ while (sibling && hops < 4) {
277
+ const snippet = (sibling.textContent ?? "").replace(/\s+/g, " ").trim();
278
+ if (snippet.length > 0) {
279
+ for (const pattern of AGENT_DIRECTED) {
280
+ if (pattern.test(snippet))
281
+ return true;
282
+ }
283
+ }
284
+ sibling = sibling.previousElementSibling;
285
+ hops += 1;
286
+ }
287
+ return false;
288
+ };
289
+ for (const el of all) {
290
+ if (seen.has(el))
291
+ continue;
292
+ seen.add(el);
293
+ // Collect up to three times the budget, then prioritise (PRD §12).
294
+ if (nodes.length >= args.maxNodes * 3)
295
+ break;
296
+ const role = roleOf(el);
297
+ const tag = el.tagName.toLowerCase();
298
+ const inputType = tag === "input" ? (el.getAttribute("type") ?? "text").toLowerCase() : undefined;
299
+ const isFile = role === "fileinput";
300
+ const visible = isVisible(el);
301
+ if (!visible && !isFile)
302
+ continue;
303
+ const r = rectOf(el);
304
+ const offscreen = r.y + r.height < 0 || r.y > innerHeight || r.x + r.width < 0 || r.x > innerWidth;
305
+ const enabled = !el.disabled &&
306
+ el.getAttribute("aria-disabled") !== "true" &&
307
+ !el.hasAttribute("inert");
308
+ const label = nameOf(el);
309
+ const flags = {
310
+ visible,
311
+ hitTarget: isFile ? true : visible ? hitTarget(el, r) : false,
312
+ enabled,
313
+ editable: false,
314
+ readonly: el.readOnly === true || el.getAttribute("aria-readonly") === "true",
315
+ required: el.required === true || el.getAttribute("aria-required") === "true",
316
+ disabled: !enabled,
317
+ inert: el.hasAttribute("inert"),
318
+ offscreen,
319
+ };
320
+ const isChoice = role === "checkbox" || role === "radio" || role === "switch";
321
+ if (isChoice) {
322
+ const checked = el.checked;
323
+ flags.checked = typeof checked === "boolean" ? checked : el.getAttribute("aria-checked") === "true";
324
+ }
325
+ const selected = el.getAttribute("aria-selected");
326
+ if (selected !== null)
327
+ flags.selected = selected === "true";
328
+ const expanded = el.getAttribute("aria-expanded");
329
+ if (expanded !== null)
330
+ flags.expanded = expanded === "true";
331
+ const isTextish = role === "textbox" || role === "searchbox" || role === "contenteditable" || role === "spinbutton";
332
+ const isEditableSelect = role === "combobox" && tag !== "select";
333
+ flags.editable =
334
+ enabled &&
335
+ !flags.readonly &&
336
+ !el.hasAttribute("inert") &&
337
+ (isTextish || isEditableSelect) &&
338
+ inputType !== "password";
339
+ // Values: never read secrets. Password and one-time-code fields stay opaque.
340
+ let value;
341
+ const autocomplete = (el.getAttribute("autocomplete") ?? "").toLowerCase();
342
+ const isPassword = inputType === "password" || autocomplete === "one-time-code" || autocomplete === "current-password" || autocomplete === "new-password";
343
+ if (isFile) {
344
+ // File inputs expose only the selected file names, never contents.
345
+ const files = el.files;
346
+ if (files && files.length > 0) {
347
+ value = Array.from(files).map((file) => file.name).join(", ").slice(0, 200);
348
+ }
349
+ }
350
+ else if (!isPassword) {
351
+ if (tag === "select") {
352
+ const sel = el;
353
+ value = sel.options[sel.selectedIndex]?.text ?? "";
354
+ }
355
+ else if (isTextish && tag !== "textarea") {
356
+ const raw = el.value;
357
+ if (typeof raw === "string" && raw.length > 0)
358
+ value = raw.slice(0, 120);
359
+ }
360
+ else if (tag === "textarea") {
361
+ const raw = el.value;
362
+ if (typeof raw === "string" && raw.length > 0)
363
+ value = raw.slice(0, 120);
364
+ }
365
+ else if (isChoice) {
366
+ value = undefined;
367
+ }
368
+ }
369
+ const options = tag === "select"
370
+ ? Array.from(el.options)
371
+ .slice(0, 80)
372
+ .map((o, i) => ({
373
+ index: i + 1,
374
+ label: collapse(o.text || o.value),
375
+ value: o.value,
376
+ disabled: o.disabled,
377
+ selected: o.selected,
378
+ }))
379
+ : undefined;
380
+ const href = tag === "a" ? el.href : undefined;
381
+ const downloadAttr = el.hasAttribute("download");
382
+ const kindParts = [];
383
+ const targetAttr = el.getAttribute("target");
384
+ if (targetAttr)
385
+ kindParts.push(targetAttr);
386
+ if (downloadAttr)
387
+ kindParts.push("download");
388
+ if (isFile)
389
+ kindParts.push("file-input");
390
+ if (el.hasAttribute("ondrop"))
391
+ kindParts.push("drop-zone");
392
+ const kind = kindParts.length > 0 ? kindParts.join(" ") : undefined;
393
+ const operations = [];
394
+ const isNativeSelect = tag === "select";
395
+ const isOption = role === "option";
396
+ if (!isNativeSelect &&
397
+ !isOption &&
398
+ (role === "button" ||
399
+ role === "link" ||
400
+ role === "checkbox" ||
401
+ role === "radio" ||
402
+ role === "switch" ||
403
+ role === "tab" ||
404
+ role === "menuitem" ||
405
+ role === "menuitemcheckbox" ||
406
+ role === "menuitemradio" ||
407
+ tag === "summary" ||
408
+ tag === "label" ||
409
+ el.hasAttribute("onclick") ||
410
+ inputType === "submit" ||
411
+ inputType === "button" ||
412
+ inputType === "reset")) {
413
+ operations.push("CLICK");
414
+ }
415
+ // Custom combobox/listbox options are clickable controls; native <option>
416
+ // elements are not collected as elements at all (they are driven by SELECT).
417
+ if (isOption)
418
+ operations.push("CLICK");
419
+ if (isTextish || isEditableSelect)
420
+ operations.push("TYPE_TEXT");
421
+ if (isNativeSelect)
422
+ operations.push("SELECT");
423
+ if (isFile)
424
+ operations.push("UPLOAD");
425
+ if ((role === "button" || role === "link" || tag === "label") &&
426
+ UPLOAD_LABEL.test(label) &&
427
+ !isFile) {
428
+ operations.push("UPLOAD");
429
+ }
430
+ if ((role === "link" || role === "button") &&
431
+ ((href !== undefined && DOWNLOAD_EXT.test(href)) || downloadAttr || DOWNLOAD_LABEL.test(label))) {
432
+ operations.push("DOWNLOAD");
433
+ }
434
+ if (operations.length === 0 && role !== "tab")
435
+ continue;
436
+ const index = nodes.length + 1;
437
+ nodeIndexByElement.set(el, index);
438
+ const formOwner = el.form;
439
+ nodes.push({
440
+ index,
441
+ nodeId: nodeIdOf(el),
442
+ role,
443
+ label,
444
+ labelHash: hash(`${role}|${label}|${tag}`),
445
+ tag,
446
+ ...(kind !== undefined ? { kind } : {}),
447
+ ...(inputType !== undefined ? { inputType } : {}),
448
+ ...(value !== undefined && value.length > 0 ? { value } : {}),
449
+ ...(el.getAttribute("placeholder") ? { placeholder: collapse(el.getAttribute("placeholder") ?? "") } : {}),
450
+ ...(el.getAttribute("title") ? { title: collapse(el.getAttribute("title") ?? "") } : {}),
451
+ ...(href !== undefined ? { href } : {}),
452
+ ...(el.getAttribute("accept") ? { accept: el.getAttribute("accept") ?? "" } : {}),
453
+ ...(el.hasAttribute("multiple") ? { multiple: true } : {}),
454
+ ...(options ? { options } : {}),
455
+ flags: { ...flags, suspicious: nearbyInstruction(el) },
456
+ operations,
457
+ ...(r.width > 0 || r.height > 0 ? { geometry: r, documentRect: docRectOf(el) } : {}),
458
+ ...(formOwner ? { formNodeId: nodeIdOf(formOwner) } : {}),
459
+ });
460
+ elements.push(el);
461
+ }
462
+ /* ── Prioritise and bound the element table (PRD §12) ───────────────────── */
463
+ const order = nodes.map((_node, i) => i);
464
+ order.sort((a, b) => {
465
+ const na = nodes[a];
466
+ const nb = nodes[b];
467
+ if (na.flags.visible !== nb.flags.visible)
468
+ return na.flags.visible ? -1 : 1;
469
+ if (na.operations.length !== nb.operations.length)
470
+ return nb.operations.length - na.operations.length;
471
+ return a - b;
472
+ });
473
+ const kept = order.slice(0, args.maxNodes);
474
+ const keptNodes = kept.map((i) => nodes[i]);
475
+ keptNodes.forEach((node, index) => {
476
+ node.index = index + 1;
477
+ });
478
+ nodes.length = 0;
479
+ nodes.push(...keptNodes);
480
+ nodeIndexByElement.clear();
481
+ kept.forEach((originalIndex, position) => {
482
+ const el = elements[originalIndex];
483
+ if (el)
484
+ nodeIndexByElement.set(el, position + 1);
485
+ });
486
+ /* ── Blocking UI (PRD §37) ─────────────────────────────────────────────── */
487
+ let blockingUi;
488
+ const modalSelectors = ['[role="dialog"]', '[role="alertdialog"]', "dialog[open]", '[aria-modal="true"]'];
489
+ let modal;
490
+ for (const sel of modalSelectors) {
491
+ const found = Array.from(document.querySelectorAll(sel)).filter(isVisible);
492
+ if (found.length > 0) {
493
+ modal = found[0];
494
+ break;
495
+ }
496
+ }
497
+ if (!modal) {
498
+ const cookie = Array.from(document.querySelectorAll('[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[aria-label*="cookie" i],[id*="gdpr" i]')).filter(isVisible);
499
+ if (cookie.length > 0)
500
+ modal = cookie[0];
501
+ }
502
+ if (modal) {
503
+ const modalNodes = [];
504
+ for (const el of Array.from(modal.querySelectorAll(selector))) {
505
+ const idx = nodeIndexByElement.get(el);
506
+ if (idx !== undefined)
507
+ modalNodes.push(idx);
508
+ if (modalNodes.length >= 10)
509
+ break;
510
+ }
511
+ const isCookie = /cookie|consent|gdpr/i.test(`${modal.id} ${modal.className}`);
512
+ blockingUi = {
513
+ kind: isCookie ? "cookie_consent" : modal.getAttribute("role") === "alertdialog" ? "confirm" : "dialog",
514
+ ...(modal.getAttribute("aria-label") ? { title: collapse(modal.getAttribute("aria-label") ?? "") } : {}),
515
+ ...(collapse(modal.innerText || "").length > 0
516
+ ? { text: collapse(modal.innerText || "").slice(0, 300) }
517
+ : {}),
518
+ nodeIndexes: modalNodes,
519
+ };
520
+ }
521
+ /* ── Visible text + injection signals ──────────────────────────────────── */
522
+ const bodyText = collapse(document.body ? document.body.innerText || "" : "");
523
+ const textSummary = bodyText.slice(0, args.maxText);
524
+ const INJECTION_PATTERNS = [
525
+ { re: /\bignore\s+(all\s+|any\s+|the\s+)?(previous|prior|above)\b/i, severity: "high" },
526
+ { re: /\b(system|assistant)\s*:/i, severity: "high" },
527
+ { re: /\byou\s+(must|should|need\s+to|are\s+required\s+to)\b/i, severity: "high" },
528
+ { re: /\bdo\s+not\s+(tell|inform|notify)\b/i, severity: "high" },
529
+ { re: /\b(password|api[\s_-]?key|secret|token|otp)\b[^.]{0,40}\b(send|share|paste|enter|upload)\b/i, severity: "high" },
530
+ ];
531
+ const injectedTexts = [];
532
+ for (const line of bodyText.split(/(?<=[.!?])\s+/).slice(0, 400)) {
533
+ if (line.length < 8 || line.length > 400)
534
+ continue;
535
+ for (const p of INJECTION_PATTERNS) {
536
+ if (p.re.test(line)) {
537
+ injectedTexts.push({ text: line.slice(0, 200), provenance: "untrusted_page_content", suspicious: true });
538
+ break;
539
+ }
540
+ }
541
+ if (injectedTexts.length >= 8)
542
+ break;
543
+ }
544
+ /* ── Fingerprint ───────────────────────────────────────────────────────── */
545
+ const parts = [];
546
+ for (const n of nodes) {
547
+ parts.push(`${n.index}|${n.role}|${n.labelHash}|${n.value ?? ""}|${n.flags.checked ?? ""}|${n.flags.selected ?? ""}|${n.flags.expanded ?? ""}|${n.flags.disabled ? 1 : 0}`);
548
+ }
549
+ const fingerprint = hash(`${location.href}|${document.title}|${Math.round(scrollY)}|${blockingUi?.kind ?? "-"}|${parts.join(";")}`);
550
+ const canvasCount = document.querySelectorAll("canvas").length;
551
+ const visualOnly = canvasCount > 0 && nodes.length === 0;
552
+ return {
553
+ url: location.href,
554
+ title: document.title,
555
+ readyState: document.readyState,
556
+ textSummary,
557
+ injectedTexts,
558
+ nodes,
559
+ viewport: {
560
+ width: innerWidth,
561
+ height: innerHeight,
562
+ scrollX: Math.round(scrollX),
563
+ scrollY: Math.round(scrollY),
564
+ documentWidth: Math.max(document.documentElement.scrollWidth, innerWidth),
565
+ documentHeight: Math.max(document.documentElement.scrollHeight, innerHeight),
566
+ },
567
+ ...(blockingUi ? { blockingUi } : {}),
568
+ fingerprint,
569
+ mutationCount: bridge.mutations,
570
+ visualOnly,
571
+ frameCount: window.frames.length,
572
+ documentToken: args.token,
573
+ };
574
+ }
575
+ export function validateTarget(args) {
576
+ /* eslint-disable @typescript-eslint/no-explicit-any */
577
+ const w = window;
578
+ const bridge = w.__mlola_bridge_v1;
579
+ if (!bridge)
580
+ return { ok: false, code: "STALE_DECISION", detail: "page bridge is missing" };
581
+ if (args.pageToken !== args.expectedToken) {
582
+ return {
583
+ ok: false,
584
+ code: "STALE_DECISION",
585
+ detail: "document identity changed since the observation",
586
+ evidence: { pageToken: args.pageToken, expected: args.expectedToken },
587
+ };
588
+ }
589
+ const ref = bridge.byId.get(args.nodeId);
590
+ const el = ref && typeof ref.deref === "function" ? ref.deref() : ref;
591
+ if (!el || !el.isConnected) {
592
+ return {
593
+ ok: false,
594
+ code: "DETACHED_TARGET",
595
+ detail: "the element is no longer connected to the document",
596
+ evidence: { nodeId: args.nodeId },
597
+ };
598
+ }
599
+ const style = getComputedStyle(el);
600
+ const isFile = el.tagName.toLowerCase() === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "file";
601
+ const visible = style.visibility !== "hidden" && style.display !== "none" && style.opacity !== "0" && el.getClientRects().length > 0;
602
+ if (!visible && !isFile) {
603
+ return { ok: false, code: "TARGET_INVALID", detail: "the target is not visible" };
604
+ }
605
+ if (isFile && !visible) {
606
+ // Hidden file inputs are the normal pattern: they are driven directly.
607
+ return { ok: true, evidence: { hiddenFileInput: true } };
608
+ }
609
+ const disabled = el.disabled === true ||
610
+ el.getAttribute("aria-disabled") === "true" ||
611
+ el.hasAttribute("inert");
612
+ if (disabled) {
613
+ return { ok: false, code: "TARGET_INVALID", detail: "the target is disabled or inert" };
614
+ }
615
+ // Structural identity: the element instance is already guaranteed by the
616
+ // node registry; this catches an in-place repurposing of the same node.
617
+ const tag = el.tagName.toLowerCase();
618
+ if (args.tag && tag !== args.tag) {
619
+ return {
620
+ ok: false,
621
+ code: "STALE_DECISION",
622
+ detail: `the node identity no longer describes a <${args.tag}>`,
623
+ evidence: { expected: args.tag, actual: tag },
624
+ };
625
+ }
626
+ if (args.role && args.role !== "other") {
627
+ const explicit = (el.getAttribute("role") ?? "").toLowerCase();
628
+ const isFileInput = tag === "input" && (el.getAttribute("type") ?? "").toLowerCase() === "file";
629
+ const irrelevant = explicit !== "" && explicit !== args.role;
630
+ if (irrelevant && !(isFileInput && args.role === "fileinput")) {
631
+ return {
632
+ ok: false,
633
+ code: "STALE_DECISION",
634
+ detail: `the node role changed since the observation`,
635
+ evidence: { expected: args.role, actual: explicit },
636
+ };
637
+ }
638
+ }
639
+ if (args.editableOnly) {
640
+ const readonly = el.readOnly === true || el.getAttribute("aria-readonly") === "true";
641
+ const isTextLike = el.hasAttribute("contenteditable") ||
642
+ el.tagName.toLowerCase() === "textarea" ||
643
+ el.tagName.toLowerCase() === "select" ||
644
+ typeof el.value === "string";
645
+ if (readonly || !isTextLike) {
646
+ return { ok: false, code: "TARGET_INVALID", detail: "the target is not editable" };
647
+ }
648
+ }
649
+ if (args.operation === "SELECT") {
650
+ const select = el;
651
+ if (select.tagName.toLowerCase() !== "select") {
652
+ return { ok: false, code: "TARGET_INVALID", detail: "the target is not a select control" };
653
+ }
654
+ const option = select.options[args.optionIndex !== undefined ? args.optionIndex - 1 : -1];
655
+ if (!option) {
656
+ return { ok: false, code: "STALE_DECISION", detail: "the option no longer exists" };
657
+ }
658
+ if (option.disabled) {
659
+ return { ok: false, code: "TARGET_INVALID", detail: "the option is disabled" };
660
+ }
661
+ if (args.optionValue !== undefined && option.value !== args.optionValue) {
662
+ return {
663
+ ok: false,
664
+ code: "STALE_DECISION",
665
+ detail: "the option value changed since the observation",
666
+ evidence: { expected: args.optionValue, actual: option.value },
667
+ };
668
+ }
669
+ }
670
+ // Geometry: bring the target into view when it is offscreen, then hit-test the
671
+ // point the runtime is about to act on (PRD §24).
672
+ let r = el.getBoundingClientRect();
673
+ if (r.width < 2 || r.height < 2) {
674
+ return { ok: false, code: "TARGET_INVALID", detail: "the target has no usable geometry" };
675
+ }
676
+ const wasOffscreen = r.bottom < 0 || r.top > innerHeight || r.right < 0 || r.left > innerWidth;
677
+ if (wasOffscreen) {
678
+ try {
679
+ el.scrollIntoView({ block: "center", inline: "nearest" });
680
+ r = el.getBoundingClientRect();
681
+ }
682
+ catch {
683
+ /* scrolling is best-effort; the hit test below decides */
684
+ }
685
+ }
686
+ if (args.requireHitTarget) {
687
+ const x = Math.min(Math.max(r.left + r.width / 2, 1), innerWidth - 2);
688
+ const y = Math.min(Math.max(r.top + r.height / 2, 1), innerHeight - 2);
689
+ const top = document.elementFromPoint(x, y);
690
+ if (!top) {
691
+ return {
692
+ ok: false,
693
+ code: "TARGET_COVERED",
694
+ detail: "nothing responds at the target point",
695
+ evidence: { point: { x, y } },
696
+ };
697
+ }
698
+ if (top !== el && !el.contains(top) && !top.contains(el)) {
699
+ const blocker = `${top.tagName.toLowerCase()}${top.id ? `#${top.id}` : ""}`;
700
+ return {
701
+ ok: false,
702
+ code: "TARGET_COVERED",
703
+ detail: `the target is covered by ${blocker}`,
704
+ evidence: { blocker, point: { x, y } },
705
+ };
706
+ }
707
+ }
708
+ return { ok: true, ...(wasOffscreen ? { evidence: { scrolledIntoView: true } } : {}) };
709
+ }
710
+ export function probeDocument() {
711
+ /* eslint-disable @typescript-eslint/no-explicit-any */
712
+ const w = window;
713
+ const bridge = w.__mlola_bridge_v1;
714
+ const out = {
715
+ url: location.href,
716
+ mutationCount: bridge ? bridge.mutations : 0,
717
+ scrollY: Math.round(scrollY),
718
+ dialogOpen: false,
719
+ };
720
+ out.dialogOpen = !!document.querySelector('[role="dialog"],[role="alertdialog"],dialog[open],[aria-modal="true"]');
721
+ return out;
722
+ }
723
+ //# sourceMappingURL=snapshot-script.js.map