skopix 2.0.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,1049 @@
1
+ import { chromium } from 'playwright';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+
5
+ export class BrowserAgent {
6
+ constructor({ headless = false, videoDir = null, sessionId }) {
7
+ // Force headless when running inside a Docker container (no display available).
8
+ // Detected via /.dockerenv which Docker always creates inside containers.
9
+ // This file does NOT exist on a bare metal Mac/Linux install, so local tests
10
+ // respect the user's headless setting while Docker tests always run headless.
11
+ // SKOPIX_HEADLESS env var can override this either way if needed.
12
+ const inDocker = fs.existsSync('/.dockerenv');
13
+ const envOverride = process.env.SKOPIX_HEADLESS;
14
+ if (envOverride === 'true') {
15
+ this.headless = true;
16
+ } else if (envOverride === 'false') {
17
+ this.headless = false;
18
+ } else if (inDocker) {
19
+ this.headless = true;
20
+ } else {
21
+ this.headless = headless;
22
+ }
23
+ this.videoDir = videoDir;
24
+ this.sessionId = sessionId;
25
+ this.browser = null;
26
+ this.context = null;
27
+ this.page = null;
28
+ // DOM caching
29
+ this._cachedDOM = null;
30
+ this._cachedURL = null;
31
+ this._stepCount = 0;
32
+ }
33
+
34
+ async launch() {
35
+ this.browser = await chromium.launch({
36
+ headless: this.headless,
37
+ args: [
38
+ '--no-sandbox',
39
+ '--disable-blink-features=AutomationControlled',
40
+ '--disable-infobars',
41
+ '--disable-features=IsolateOrigins,site-per-process,SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure',
42
+ '--disable-site-isolation-trials',
43
+ '--allow-insecure-localhost',
44
+ ],
45
+ });
46
+
47
+ const contextOptions = {
48
+ viewport: { width: 1280, height: 800 },
49
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
50
+ locale: 'en-GB',
51
+ };
52
+
53
+ if (this.videoDir) {
54
+ await fs.ensureDir(this.videoDir);
55
+ contextOptions.recordVideo = { dir: this.videoDir, size: { width: 1280, height: 800 } };
56
+ }
57
+
58
+ this.context = await this.browser.newContext(contextOptions);
59
+ this.page = await this.context.newPage();
60
+
61
+ await this.page.addInitScript(() => {
62
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
63
+ });
64
+
65
+ this.consoleErrors = [];
66
+ this.page.on('console', (msg) => {
67
+ if (msg.type() === 'error') this.consoleErrors.push(msg.text());
68
+ });
69
+
70
+ this.networkErrors = [];
71
+ this.page.on('response', (response) => {
72
+ if (response.status() >= 400) {
73
+ this.networkErrors.push({ url: response.url(), status: response.status() });
74
+ }
75
+ });
76
+ }
77
+
78
+ async goto(url) {
79
+ if (!url.startsWith('http')) url = 'https://' + url;
80
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
81
+ await this.page.waitForTimeout(2000);
82
+ this._cachedDOM = null; // clear cache on navigation
83
+ }
84
+
85
+ async currentUrl() {
86
+ return this.page.url();
87
+ }
88
+
89
+ async screenshot(filePath) {
90
+ try {
91
+ await this.page.screenshot({ path: filePath, fullPage: false });
92
+ return filePath;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ async extractDOM() {
99
+ this._stepCount++;
100
+ const currentUrl = await this.page.url();
101
+ const urlChanged = currentUrl !== this._cachedURL;
102
+ const previousTitle = this._cachedTitle;
103
+
104
+ // Always do full extraction
105
+ const raw = await this._extractRawDOM();
106
+
107
+ // Compress the DOM - remove noise and deduplicate
108
+ const compressed = this._compressDOM(raw);
109
+
110
+ // On first step or URL change: full snapshot
111
+ if (!this._cachedDOM || urlChanged || this._stepCount <= 1) {
112
+ this._cachedDOM = compressed;
113
+ this._cachedURL = currentUrl;
114
+ this._cachedTitle = raw.title;
115
+ return {
116
+ raw,
117
+ text: this._serialise(compressed, false, null, previousTitle),
118
+ };
119
+ }
120
+
121
+ const diff = this._diffDOM(this._cachedDOM, compressed);
122
+ diff.titleChanged = previousTitle !== raw.title;
123
+ diff.previousTitle = previousTitle;
124
+ this._cachedDOM = compressed;
125
+ this._cachedURL = currentUrl;
126
+ this._cachedTitle = raw.title;
127
+
128
+ return {
129
+ raw,
130
+ text: this._serialise(compressed, true, diff, previousTitle),
131
+ };
132
+ }
133
+
134
+ async _extractRawDOM() {
135
+ const result = await this.page.evaluate(() => {
136
+ const interactive = [];
137
+
138
+ function getText(el) {
139
+ return (el.textContent || el.innerText || el.value || el.placeholder || el.alt || el.title || '').trim().replace(/\s+/g, ' ').slice(0, 80);
140
+ }
141
+
142
+ function getRole(el) {
143
+ return el.getAttribute('role') || el.tagName.toLowerCase();
144
+ }
145
+
146
+ function isRedOrWarning(el) {
147
+ try {
148
+ const style = window.getComputedStyle(el);
149
+ const combined = (style.color + style.backgroundColor).toLowerCase();
150
+ if (combined.includes('rgb(255, 0') || combined.includes('rgb(220') || combined.includes('rgb(239') || combined.includes('rgb(211')) return true;
151
+ const cls = (el.className || '').toLowerCase();
152
+ if (cls.includes('warn') || cls.includes('error') || cls.includes('danger') || cls.includes('alert') || cls.includes('critical')) return true;
153
+ return false;
154
+ } catch { return false; }
155
+ }
156
+
157
+ const seen = new Set();
158
+ const selectors = [
159
+ 'a[href]', 'button', 'input', 'select', 'textarea',
160
+ '[role="button"]', '[role="link"]', '[role="menuitem"]', '[role="tab"]',
161
+ '[onclick]', '[tabindex]', 'svg', 'i[class]', 'span[class]', 'div[class]',
162
+ ];
163
+
164
+ document.querySelectorAll(selectors.join(',')).forEach((el) => {
165
+ const rect = el.getBoundingClientRect();
166
+ if (rect.width === 0 || rect.height === 0) return;
167
+ if (rect.width > 600 && rect.height > 400) return;
168
+ if (!el.offsetParent && el.tagName !== 'BODY' && el.tagName !== 'SVG') return;
169
+
170
+ const key = Math.round(rect.x) + ',' + Math.round(rect.y) + ',' + Math.round(rect.width);
171
+ if (seen.has(key)) return;
172
+ seen.add(key);
173
+
174
+ const cls = typeof el.className === 'string' ? el.className : '';
175
+ const text = getText(el);
176
+ const ariaLabel = el.getAttribute('aria-label') || '';
177
+ const title = el.getAttribute('title') || '';
178
+ const isSmall = rect.width < 80 && rect.height < 80;
179
+ const hasMeaning = text || ariaLabel || title || isRedOrWarning(el) || cls.includes('icon') || cls.includes('btn') || cls.includes('warn') || cls.includes('error');
180
+
181
+ if (!isSmall && !hasMeaning) return;
182
+
183
+ interactive.push({
184
+ tag: el.tagName.toLowerCase(),
185
+ type: el.type || null,
186
+ text: text.slice(0, 60),
187
+ cls: cls.slice(0, 100),
188
+ ariaLabel: ariaLabel.slice(0, 60),
189
+ title: title.slice(0, 60),
190
+ disabled: el.disabled || false,
191
+ isWarning: isRedOrWarning(el),
192
+ x: Math.round(rect.x),
193
+ y: Math.round(rect.y),
194
+ w: Math.round(rect.width),
195
+ h: Math.round(rect.height),
196
+ });
197
+ });
198
+
199
+ // Content - only headings and visible error/alert text
200
+ const content = [];
201
+ document.querySelectorAll('h1,h2,h3,h4,[role="heading"],[class*="error"],[class*="warning"],[class*="exception"],[class*="alert"]').forEach((el) => {
202
+ const text = getText(el);
203
+ if (text.length > 3) content.push({ tag: el.tagName.toLowerCase(), text });
204
+ });
205
+
206
+ // Overlays - tooltips, modals, popups
207
+ const overlays = [];
208
+ document.querySelectorAll('[class*="tooltip"],[class*="modal"],[class*="popup"],[class*="dialog"],[role="dialog"],[role="tooltip"]').forEach((el) => {
209
+ const text = getText(el);
210
+ if (text.length > 3) overlays.push(text);
211
+ });
212
+
213
+ const alerts = [];
214
+ document.querySelectorAll('[role="alert"],.error,.alert,[class*="error"],[class*="exception"]').forEach((el) => {
215
+ const text = getText(el);
216
+ if (text.length > 3) alerts.push(text);
217
+ });
218
+
219
+ const forms = Array.from(document.forms).map((f) => ({
220
+ action: f.action,
221
+ fields: Array.from(f.elements).map((e) => ({
222
+ id: e.id, name: e.name, type: e.type, placeholder: e.placeholder, required: e.required,
223
+ label: (function() {
224
+ if (e.id) {
225
+ const lbl = document.querySelector('label[for="' + e.id + '"]');
226
+ if (lbl) return (lbl.textContent || '').trim().slice(0, 50);
227
+ }
228
+ return '';
229
+ })(),
230
+ value: e.type === 'password' ? '[hidden]' : (e.value || '').slice(0, 40),
231
+ })),
232
+ }));
233
+
234
+ // Change 5 (v2): hierarchical navigation tree extraction.
235
+ // Detects standard navs PLUS Bootstrap list-group, custom collapsible widgets,
236
+ // FontAwesome chevron icons, and any repeated category-style structures.
237
+ const navTree = (function buildNavTree() {
238
+ const items = [];
239
+
240
+ // Helper: visible check, doesn't trust just rect (CSS visibility/opacity matter too)
241
+ function isVisible(el) {
242
+ if (!el) return false;
243
+ const r = el.getBoundingClientRect();
244
+ if (r.width === 0 || r.height === 0) return false;
245
+ try {
246
+ const cs = window.getComputedStyle(el);
247
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
248
+ } catch {}
249
+ return true;
250
+ }
251
+
252
+ // 1) Find containers. Three tiers of broadness:
253
+ // Tier A: explicit nav semantics (nav, aside, role=navigation, etc.)
254
+ // Tier B: well-known class patterns (sidebar, menu, tree, categories, collapsible, list-group)
255
+ // Tier C: structural fallback (any container with 3+ similar repeating children that look clickable)
256
+ const containers = new Set();
257
+
258
+ const tierASelectors = [
259
+ 'nav', 'aside',
260
+ '[role="navigation"]', '[role="tree"]', '[role="menu"]', '[role="listbox"]',
261
+ ];
262
+ const tierBSelectors = [
263
+ '[class*="sidebar" i]', '[class*="side-bar" i]',
264
+ '[class*="sidenav" i]', '[class*="side-nav" i]',
265
+ '[class*="navmenu" i]', '[class*="nav-menu" i]',
266
+ '[class*="category-list" i]', '[class*="categories" i]',
267
+ '[class*="tree" i]', '[class*="menu-tree" i]',
268
+ '[class*="list-group" i]', '[class*="listgroup" i]',
269
+ '[class*="collapsible" i]', '[class*="collapse-list" i]',
270
+ '[class*="accordion" i]',
271
+ '[id*="sidebar" i]', '[id*="nav" i]', '[id*="menu" i]',
272
+ ];
273
+ for (const sel of [...tierASelectors, ...tierBSelectors]) {
274
+ try {
275
+ document.querySelectorAll(sel).forEach(c => {
276
+ if (isVisible(c)) containers.add(c);
277
+ });
278
+ } catch {}
279
+ }
280
+
281
+ // Tier C fallback: find any element with 3+ similar repeating children (sibling pattern).
282
+ // This catches custom widgets like Panintelligence's that don't match standard class names.
283
+ // We look for a parent whose direct children share a common class prefix.
284
+ if (containers.size < 2) {
285
+ const allCandidates = document.querySelectorAll('div, ul, section');
286
+ for (const parent of allCandidates) {
287
+ if (!isVisible(parent)) continue;
288
+ const r = parent.getBoundingClientRect();
289
+ // Heuristic: must be reasonably sized and not the whole page
290
+ if (r.width < 100 || r.width > 700) continue;
291
+ if (r.height < 100) continue;
292
+ const children = Array.from(parent.children).filter(c => isVisible(c));
293
+ if (children.length < 3) continue;
294
+ // Compare first-child class prefix
295
+ const firstCls = ((children[0].className || '') + '').split(/\s+/)[0] || '';
296
+ if (!firstCls || firstCls.length < 4) continue;
297
+ const matching = children.filter(c => ((c.className || '') + '').includes(firstCls)).length;
298
+ if (matching / children.length < 0.6) continue;
299
+ containers.add(parent);
300
+ }
301
+ }
302
+
303
+ // 2) Detect expandable state for an item
304
+ function isExpandable(el) {
305
+ if (!el) return null;
306
+ // Strongest: aria-expanded
307
+ const aria = el.getAttribute('aria-expanded');
308
+ if (aria !== null) return { expandable: true, expanded: aria === 'true' };
309
+
310
+ // Check for FontAwesome / icon chevrons that toggle visibility
311
+ // Pattern: two sibling icons, one for collapsed state, one for expanded.
312
+ // Whichever is visible tells us the current state.
313
+ const chevronDown = el.querySelector('[class*="chevron-down" i], [class*="caret-down" i], [class*="angle-down" i], [class*="arrow-down" i]');
314
+ const chevronRight = el.querySelector('[class*="chevron-right" i], [class*="caret-right" i], [class*="angle-right" i], [class*="arrow-right" i]');
315
+ const chevronUp = el.querySelector('[class*="chevron-up" i], [class*="caret-up" i], [class*="angle-up" i], [class*="arrow-up" i]');
316
+ if (chevronDown || chevronRight || chevronUp) {
317
+ // Determine which one is visible
318
+ const downVisible = chevronDown && isVisible(chevronDown);
319
+ const rightVisible = chevronRight && isVisible(chevronRight);
320
+ const upVisible = chevronUp && isVisible(chevronUp);
321
+ // chevron-down OR chevron-up shown = expanded
322
+ // chevron-right shown = collapsed
323
+ if (downVisible || upVisible) return { expandable: true, expanded: true };
324
+ if (rightVisible) return { expandable: true, expanded: false };
325
+ // Both exist but we couldn't determine visibility - assume collapsed (safer default)
326
+ return { expandable: true, expanded: false };
327
+ }
328
+
329
+ // Generic class hints
330
+ const cls = ((el.className || '') + '').toLowerCase();
331
+ const hasChevronClass = /\b(chevron|caret|arrow|toggle|expand|collapse|disclosure)\b/.test(cls);
332
+ const hasChevronChild = el.querySelector('[class*="chevron" i], [class*="caret" i], [class*="arrow" i], [class*="expand" i]');
333
+ const hasChevronUnicode = /[▸▶▾▼►▽]/.test(el.textContent || '');
334
+ if (hasChevronClass || hasChevronChild || hasChevronUnicode) {
335
+ const expanded = /\b(open|expanded|active|in)\b/.test(cls);
336
+ return { expandable: true, expanded: !!expanded };
337
+ }
338
+
339
+ // Nested UL/OL/div that's currently hidden = collapsed expandable
340
+ const nested = el.querySelector(':scope > ul, :scope > ol, :scope > div > ul, :scope > [class*="submenu" i], :scope > [class*="children" i]');
341
+ if (nested) {
342
+ return { expandable: true, expanded: isVisible(nested) };
343
+ }
344
+
345
+ // Has a data-toggle attribute (Bootstrap collapse pattern)
346
+ if (el.getAttribute('data-toggle') === 'collapse' || el.hasAttribute('data-bs-toggle')) {
347
+ return { expandable: true, expanded: false };
348
+ }
349
+
350
+ return null;
351
+ }
352
+
353
+ function getItemText(el) {
354
+ // Try most-specific labels first
355
+ // 1. title attribute on element or its labelled span
356
+ const titleSpan = el.querySelector('[title]');
357
+ if (titleSpan && titleSpan.title && titleSpan.title.length > 1) return titleSpan.title.slice(0, 60);
358
+ if (el.title && el.title.length > 1) return el.title.slice(0, 60);
359
+ // 2. aria-label
360
+ const aria = el.getAttribute('aria-label');
361
+ if (aria && aria.length > 1) return aria.slice(0, 60);
362
+ // 3. direct text nodes on the element itself (not descendants)
363
+ const ownText = Array.from(el.childNodes)
364
+ .filter(n => n.nodeType === 3)
365
+ .map(n => n.textContent || '')
366
+ .join(' ').trim();
367
+ if (ownText.length > 2) return ownText.slice(0, 60);
368
+ // 4. First label-bearing span/anchor
369
+ const span = el.querySelector('span, a, [class*="label" i], [class*="title" i], [class*="name" i], [class*="text" i]');
370
+ if (span) {
371
+ const t = (span.textContent || '').trim();
372
+ if (t.length > 0) return t.slice(0, 60);
373
+ }
374
+ // 5. Last resort: all text content
375
+ return (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
376
+ }
377
+
378
+ function buildPath(el, container) {
379
+ const labels = [];
380
+ let cur = el.parentElement;
381
+ let depth = 0;
382
+ while (cur && cur !== container && depth < 6) {
383
+ const role = cur.getAttribute && cur.getAttribute('role');
384
+ const isPotentialParent = cur.tagName === 'LI' || role === 'treeitem' || role === 'menuitem' ||
385
+ ((cur.className || '') + '').toLowerCase().match(/\b(list-group-item|category|menu-item|nav-item|collapsible-item)\b/);
386
+ if (isPotentialParent) {
387
+ const parentLabel = getItemText(cur);
388
+ if (parentLabel && parentLabel.length > 1) labels.unshift(parentLabel);
389
+ }
390
+ cur = cur.parentElement;
391
+ depth++;
392
+ }
393
+ return labels;
394
+ }
395
+
396
+ // 3) Walk each container and collect items
397
+ // First pass: items by recognised selectors. These are the "obvious" nav items.
398
+ const explicitItemSelectors = [
399
+ 'a[href]', 'li',
400
+ '[role="menuitem"]', '[role="treeitem"]', '[role="option"]', '[role="button"]',
401
+ '[class*="menu-item" i]', '[class*="nav-item" i]',
402
+ '[class*="list-group-item" i]:not([class*="chevron" i]):not([class*="icon" i])',
403
+ '[class*="listgroup-item" i]',
404
+ '[class*="collapsible-item" i]',
405
+ '[onclick]', '[ng-click]',
406
+ // Test-id attributes are strong nav-item signals on custom widgets
407
+ '[pi-test-identifier]', '[data-testid]', '[data-test]',
408
+ ].join(',');
409
+
410
+ function isItemLike(el) {
411
+ // Heuristic for divs / spans that look like nav items even without classes
412
+ if (!el || !el.tagName) return false;
413
+ // Skip leaf icons or pure-text spans that are children of other items
414
+ if (el.tagName === 'I' || el.tagName === 'SVG') return false;
415
+ // Must have direct text content OR a child with text + a clickable behaviour
416
+ const hasIdentifier = el.hasAttribute('pi-test-identifier') || el.hasAttribute('data-testid') || el.hasAttribute('data-test');
417
+ if (hasIdentifier) return true;
418
+ return false;
419
+ }
420
+
421
+ containers.forEach(container => {
422
+ let candidates;
423
+ try { candidates = Array.from(container.querySelectorAll(explicitItemSelectors)); }
424
+ catch { return; }
425
+
426
+ // Second pass: also walk children of container looking for item-like divs
427
+ // (e.g. <div pi-test-identifier="..."> without a recognised class)
428
+ try {
429
+ container.querySelectorAll('div, span').forEach(el => {
430
+ if (isItemLike(el)) candidates.push(el);
431
+ });
432
+ } catch {}
433
+
434
+ candidates.forEach(el => {
435
+ if (!isVisible(el)) return;
436
+ const text = getItemText(el);
437
+ if (!text || text.length < 1) return;
438
+ // Avoid emitting things that are themselves the container
439
+ if (containers.has(el)) return;
440
+ // Avoid emitting standalone chevron icons even if they slipped through
441
+ const tag = el.tagName.toLowerCase();
442
+ if (tag === 'i' || tag === 'svg') return;
443
+ // Avoid emitting spans/icons that are children of another item we've already captured
444
+ // (we want the outer clickable, not the label inside it)
445
+ const cls = ((el.className || '') + '').toLowerCase();
446
+ if (cls.match(/\b(chevron|caret|arrow|icon|fa-)\b/)) return;
447
+ const parents = buildPath(el, container);
448
+ const exp = isExpandable(el);
449
+ // Generate a stable selector
450
+ let selector = null;
451
+ if (el.id) selector = '#' + el.id;
452
+ else if (el.tagName === 'A' && el.href) selector = 'a[href="' + el.href + '"]';
453
+ else if (el.getAttribute('pi-test-identifier')) selector = '[pi-test-identifier="' + el.getAttribute('pi-test-identifier') + '"]';
454
+ else if (el.getAttribute('data-testid')) selector = '[data-testid="' + el.getAttribute('data-testid') + '"]';
455
+ else if (el.getAttribute('data-test')) selector = '[data-test="' + el.getAttribute('data-test') + '"]';
456
+ const r = el.getBoundingClientRect();
457
+ items.push({
458
+ text, parents, depth: parents.length,
459
+ x: Math.round(r.x), y: Math.round(r.y),
460
+ w: Math.round(r.width), h: Math.round(r.height),
461
+ expandable: exp ? exp.expandable : false,
462
+ expanded: exp ? exp.expanded : false,
463
+ tag: el.tagName.toLowerCase(),
464
+ selector,
465
+ });
466
+ });
467
+ });
468
+
469
+ // Deduplicate by text + depth + approximate y position
470
+ const seen = new Set();
471
+ const deduped = [];
472
+ for (const it of items) {
473
+ const key = it.text + '|' + it.depth + '|' + Math.round(it.y / 10);
474
+ if (seen.has(key)) continue;
475
+ seen.add(key);
476
+ deduped.push(it);
477
+ }
478
+ return deduped;
479
+ })();
480
+
481
+ return {
482
+ url: window.location.href,
483
+ title: document.title,
484
+ interactive: interactive.slice(0, 100),
485
+ content: content.slice(0, 20),
486
+ overlays: overlays.slice(0, 5),
487
+ alerts: alerts.slice(0, 8),
488
+ forms,
489
+ navTree: navTree.slice(0, 60),
490
+ };
491
+ });
492
+
493
+ // Add console/network errors
494
+ result.consoleErrors = (this.consoleErrors || []).slice(-3);
495
+ result.networkErrors = (this.networkErrors || []).slice(-3);
496
+ return result;
497
+ }
498
+
499
+ _compressDOM(raw) {
500
+ // Deduplicate interactive elements that are identical in text+class
501
+ const seen = new Map();
502
+ const deduped = [];
503
+ for (const el of raw.interactive) {
504
+ const key = el.tag + '|' + el.text + '|' + el.cls.slice(0, 40);
505
+ if (seen.has(key)) {
506
+ seen.get(key).count = (seen.get(key).count || 1) + 1;
507
+ continue;
508
+ }
509
+ const item = { ...el };
510
+ seen.set(key, item);
511
+ deduped.push(item);
512
+ }
513
+
514
+ // Group nav/sidebar items - if more than 5 similar items collapse them
515
+ const navItems = deduped.filter(el => el.x < 280 && !el.isWarning);
516
+ const mainItems = deduped.filter(el => el.x >= 280 || el.isWarning);
517
+
518
+ return {
519
+ ...raw,
520
+ interactive: deduped,
521
+ navItems: navItems.slice(0, 15),
522
+ mainItems: mainItems.slice(0, 50),
523
+ navTree: raw.navTree || [], // pass through from raw extraction
524
+ };
525
+ }
526
+
527
+ _diffDOM(prev, curr) {
528
+ const diff = { added: [], removed: [], changed: [], overlaysChanged: false, alertsChanged: false };
529
+
530
+ // Check for new/removed elements by text+position key
531
+ const prevKeys = new Set(prev.interactive.map(e => e.tag + '|' + e.text + '|' + e.x + ',' + e.y));
532
+ const currKeys = new Set(curr.interactive.map(e => e.tag + '|' + e.text + '|' + e.x + ',' + e.y));
533
+
534
+ curr.interactive.forEach(el => {
535
+ const key = el.tag + '|' + el.text + '|' + el.x + ',' + el.y;
536
+ if (!prevKeys.has(key)) diff.added.push(el);
537
+ });
538
+
539
+ prev.interactive.forEach(el => {
540
+ const key = el.tag + '|' + el.text + '|' + el.x + ',' + el.y;
541
+ if (!currKeys.has(key)) diff.removed.push({ tag: el.tag, text: el.text });
542
+ });
543
+
544
+ // Check overlays and alerts changed
545
+ const prevOverlays = JSON.stringify(prev.overlays);
546
+ const currOverlays = JSON.stringify(curr.overlays);
547
+ diff.overlaysChanged = prevOverlays !== currOverlays;
548
+ diff.newOverlays = curr.overlays;
549
+
550
+ const prevAlerts = JSON.stringify(prev.alerts);
551
+ const currAlerts = JSON.stringify(curr.alerts);
552
+ diff.alertsChanged = prevAlerts !== currAlerts;
553
+ diff.newAlerts = curr.alerts;
554
+
555
+ return diff;
556
+ }
557
+
558
+ _serialise(dom, isDiff, diff, previousTitle) {
559
+ const lines = [
560
+ 'PAGE: ' + dom.title,
561
+ 'URL: ' + dom.url,
562
+ ];
563
+
564
+ if (isDiff && diff && diff.titleChanged) {
565
+ lines.push('*** PAGE TITLE CHANGED FROM "' + (previousTitle || '') + '" TO "' + dom.title + '" - This usually means navigation succeeded! Set goalAchieved if appropriate. ***');
566
+ }
567
+ lines.push('');
568
+
569
+ if (isDiff && diff) {
570
+ lines.push('--- PAGE CHANGES SINCE LAST STEP ---');
571
+
572
+ if (diff.added.length === 0 && diff.removed.length === 0 && !diff.overlaysChanged && !diff.alertsChanged) {
573
+ lines.push('(no changes detected)');
574
+ }
575
+
576
+ if (diff.added.length > 0) {
577
+ lines.push('NEW ELEMENTS:');
578
+ diff.added.slice(0, 20).forEach(el => {
579
+ const label = el.ariaLabel || el.title || el.text || '(no label)';
580
+ const warn = el.isWarning ? ' WARNING-STYLED' : '';
581
+ lines.push(' + ' + el.tag + ' | "' + label + '" | class:"' + el.cls.slice(0, 60) + '" | pos:(' + el.x + ',' + el.y + ') | size:' + el.w + 'x' + el.h + warn);
582
+ });
583
+ }
584
+
585
+ if (diff.removed.length > 0) {
586
+ lines.push('REMOVED ELEMENTS:');
587
+ diff.removed.slice(0, 10).forEach(el => {
588
+ lines.push(' - ' + el.tag + ' | "' + el.text + '"');
589
+ });
590
+ }
591
+
592
+ if (diff.overlaysChanged && diff.newOverlays.length > 0) {
593
+ lines.push('OVERLAY/TOOLTIP/MODAL TEXT:');
594
+ diff.newOverlays.forEach(t => lines.push(' OVERLAY: ' + t));
595
+ }
596
+
597
+ if (diff.alertsChanged && diff.newAlerts.length > 0) {
598
+ lines.push('ALERTS/ERRORS:');
599
+ diff.newAlerts.forEach(a => lines.push(' ALERT: ' + a));
600
+ }
601
+
602
+ // After expansion, the nav tree often has NEW items revealed.
603
+ // Show the current full nav tree on every step so the LLM can see expanded children.
604
+ if (dom.navTree && dom.navTree.length > 0) {
605
+ lines.push('', '--- NAVIGATION/SIDEBAR (current state) ---');
606
+ dom.navTree.forEach((it, i) => {
607
+ const indent = ' '.repeat(it.depth);
608
+ const hints = [];
609
+ if (it.expandable && !it.expanded) hints.push('[expandable, COLLAPSED - click to reveal children]');
610
+ else if (it.expandable && it.expanded) hints.push('[expandable, expanded]');
611
+ const path = it.parents.length > 0 ? ' under: ' + it.parents.join(' > ') : '';
612
+ const sel = it.selector ? ' selector:"' + it.selector + '"' : '';
613
+ lines.push('[nav-' + i + '] ' + indent + it.tag + ' | "' + it.text + '"' + path + sel + ' ' + hints.join(' '));
614
+ });
615
+ }
616
+
617
+ // Always include form fields with their selectors - critical for TYPE actions
618
+ if (dom.forms && dom.forms.length > 0) {
619
+ lines.push('', '--- FORM FIELDS (always use the id selector for TYPE/SELECT) ---');
620
+ dom.forms.forEach(f => {
621
+ f.fields.forEach(field => {
622
+ if (!field.type || field.type === 'hidden' || field.type === 'submit' || field.type === 'button') return;
623
+ const targetSel = field.id ? '#' + field.id : (field.name ? '[name="' + field.name + '"]' : '');
624
+ if (!targetSel) return;
625
+ lines.push(' TARGET: "' + targetSel + '" | label:"' + (field.label || '') + '" | type:' + field.type + ' | value:"' + (field.value || '') + '"' + (field.required ? ' REQUIRED' : ''));
626
+ });
627
+ });
628
+ }
629
+
630
+ // Always include warning-styled elements regardless of diff
631
+ const warnings = dom.mainItems.filter(el => el.isWarning);
632
+ if (warnings.length > 0) {
633
+ lines.push('', 'WARNING/ERROR ELEMENTS (always shown):');
634
+ warnings.forEach(el => {
635
+ const label = el.ariaLabel || el.title || el.text || '(no label)';
636
+ lines.push(' ! ' + el.tag + ' | "' + label + '" | class:"' + el.cls.slice(0, 60) + '" | pos:(' + el.x + ',' + el.y + ') | size:' + el.w + 'x' + el.h);
637
+ });
638
+ }
639
+
640
+ } else {
641
+ // Full snapshot on first step
642
+ lines.push('--- NAVIGATION/SIDEBAR ---');
643
+ // Prefer the hierarchical nav tree when available (gives the LLM parent/child + expandable hints)
644
+ if (dom.navTree && dom.navTree.length > 0) {
645
+ dom.navTree.forEach((it, i) => {
646
+ const indent = ' '.repeat(it.depth);
647
+ const hints = [];
648
+ if (it.expandable && !it.expanded) hints.push('[expandable, COLLAPSED - click to reveal children]');
649
+ else if (it.expandable && it.expanded) hints.push('[expandable, expanded]');
650
+ const path = it.parents.length > 0 ? ' under: ' + it.parents.join(' > ') : '';
651
+ const sel = it.selector ? ' selector:"' + it.selector + '"' : '';
652
+ lines.push('[nav-' + i + '] ' + indent + it.tag + ' | "' + it.text + '"' + path + sel + ' | pos:(' + it.x + ',' + it.y + ') ' + hints.join(' '));
653
+ });
654
+ } else {
655
+ // Fallback to the old flat navItems list
656
+ dom.navItems.forEach((el, i) => {
657
+ const label = el.ariaLabel || el.title || el.text || '(no label)';
658
+ lines.push('[nav-' + i + '] ' + el.tag + ' | "' + label + '" | pos:(' + el.x + ',' + el.y + ')');
659
+ });
660
+ }
661
+
662
+ lines.push('', '--- MAIN CONTENT ELEMENTS ---');
663
+ dom.mainItems.forEach((el, i) => {
664
+ const label = el.ariaLabel || el.title || el.text || '(no label)';
665
+ const warn = el.isWarning ? ' WARNING-STYLED' : '';
666
+ lines.push('[' + i + '] ' + el.tag + ' | "' + label + '" | class:"' + el.cls.slice(0, 60) + '" | pos:(' + el.x + ',' + el.y + ') | size:' + el.w + 'x' + el.h + warn);
667
+ });
668
+
669
+ if (dom.forms && dom.forms.length > 0) {
670
+ lines.push('', '--- FORM FIELDS (use the id as the target for TYPE/SELECT actions) ---');
671
+ dom.forms.forEach(f => {
672
+ f.fields.forEach(field => {
673
+ if (!field.type || field.type === 'hidden' || field.type === 'submit' || field.type === 'button') return;
674
+ const targetSel = field.id ? '#' + field.id : (field.name ? '[name="' + field.name + '"]' : '');
675
+ if (!targetSel) return;
676
+ lines.push(' TARGET: "' + targetSel + '" | label:"' + (field.label || '') + '" | type:' + field.type + ' | placeholder:"' + (field.placeholder || '') + '" | value:"' + (field.value || '') + '"' + (field.required ? ' REQUIRED' : ''));
677
+ });
678
+ });
679
+ }
680
+
681
+ if (dom.content.length > 0) {
682
+ lines.push('', '--- PAGE CONTENT ---');
683
+ dom.content.forEach(c => lines.push(c.tag + ': ' + c.text));
684
+ }
685
+
686
+ if (dom.overlays.length > 0) {
687
+ lines.push('', '--- OVERLAYS/TOOLTIPS ---');
688
+ dom.overlays.forEach(t => lines.push(' OVERLAY: ' + t));
689
+ }
690
+
691
+ if (dom.alerts.length > 0) {
692
+ lines.push('', '--- ALERTS/ERRORS ---');
693
+ dom.alerts.forEach(a => lines.push(' ALERT: ' + a));
694
+ }
695
+ }
696
+
697
+ if (dom.consoleErrors && dom.consoleErrors.length > 0) {
698
+ lines.push('', '--- CONSOLE ERRORS ---');
699
+ dom.consoleErrors.forEach(e => lines.push(' JS: ' + e));
700
+ }
701
+
702
+ if (dom.networkErrors && dom.networkErrors.length > 0) {
703
+ lines.push('', '--- NETWORK ERRORS ---');
704
+ dom.networkErrors.forEach(e => lines.push(' HTTP ' + e.status + ': ' + e.url));
705
+ }
706
+
707
+ return lines.join('\n');
708
+ }
709
+
710
+ async _waitForStable() {
711
+ // Adaptive wait - waits for network idle, capped at 3s for slow pages
712
+ try {
713
+ await Promise.race([
714
+ this.page.waitForLoadState('networkidle', { timeout: 3000 }),
715
+ this.page.waitForTimeout(3000),
716
+ ]);
717
+ } catch {
718
+ // networkidle timed out, that's fine
719
+ }
720
+ // Always give DOM at least 400ms to settle for animations
721
+ await this.page.waitForTimeout(400);
722
+ }
723
+
724
+ async executeAction(decision) {
725
+ const { action, target, value } = decision;
726
+
727
+ if (['CLICK', 'TYPE', 'SELECT', 'PRESS', 'NAVIGATE', 'CLICK_AT', 'BATCH'].includes(action)) {
728
+ this._cachedDOM = null;
729
+ }
730
+
731
+ // Handle BATCH - execute multiple actions in sequence
732
+ if (action === 'BATCH' && Array.isArray(decision.actions)) {
733
+ const results = [];
734
+ for (const subAction of decision.actions) {
735
+ const subResult = await this.executeAction(subAction);
736
+ results.push({ action: subAction.action, target: subAction.target, ...subResult });
737
+ if (!subResult.success) break; // stop batch on first failure
738
+ }
739
+ const allSucceeded = results.every(r => r.success);
740
+ return {
741
+ success: allSucceeded,
742
+ batchResults: results,
743
+ error: allSucceeded ? null : 'Batch stopped at: ' + (results.find(r => !r.success)?.action || 'unknown'),
744
+ };
745
+ }
746
+
747
+ try {
748
+ switch (action) {
749
+ case 'CLICK': {
750
+ const el = await this._resolveElement(target);
751
+ if (!el) throw new Error('Element not found: ' + target);
752
+ try {
753
+ await el.scrollIntoViewIfNeeded({ timeout: 2000 });
754
+ } catch {}
755
+
756
+ // Change 4: pre-submit form completeness check.
757
+ // If this looks like a submit button, scan the containing form for empty
758
+ // required inputs. If any are empty, refuse the click and surface a clear
759
+ // error - the LLM sees this in history and re-plans to fill the missing field.
760
+ const targetLowerEarly = (target || '').toLowerCase();
761
+ const looksLikeSubmitEarly = targetLowerEarly.includes('login') || targetLowerEarly.includes('submit') || targetLowerEarly.includes('sign in') || targetLowerEarly.includes('sign up') || targetLowerEarly.includes('continue') || targetLowerEarly.includes('register') || targetLowerEarly.includes('next');
762
+ if (looksLikeSubmitEarly) {
763
+ try {
764
+ const emptyRequired = await this.page.evaluate(() => {
765
+ // Find the most relevant form: visible, has required inputs.
766
+ const forms = Array.from(document.forms);
767
+ if (forms.length === 0) return [];
768
+ // Pick the first visible form
769
+ const form = forms.find(f => {
770
+ const r = f.getBoundingClientRect();
771
+ return r.width > 0 && r.height > 0;
772
+ }) || forms[0];
773
+ const empties = [];
774
+ for (const field of Array.from(form.elements)) {
775
+ // Skip non-input things, hidden inputs, disabled, buttons
776
+ if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(field.tagName)) continue;
777
+ if (field.type === 'hidden' || field.type === 'submit' || field.type === 'button') continue;
778
+ if (field.disabled) continue;
779
+ // Only consider visible fields
780
+ const r = field.getBoundingClientRect();
781
+ if (r.width === 0 || r.height === 0) continue;
782
+ // Only consider those that look required (required attr, aria-required, or labelled required)
783
+ const isRequired = field.required || field.getAttribute('aria-required') === 'true';
784
+ if (!isRequired) continue;
785
+ const value = (field.value || '').trim();
786
+ if (value.length === 0) {
787
+ const id = field.id || field.name || field.type || field.tagName;
788
+ empties.push(id);
789
+ }
790
+ }
791
+ return empties;
792
+ });
793
+ if (emptyRequired && emptyRequired.length > 0) {
794
+ throw new Error('Form has empty required field(s): ' + emptyRequired.join(', ') + '. Fill these before submitting.');
795
+ }
796
+ } catch (err) {
797
+ // Only rethrow the "empty required" error - other evaluate errors we swallow
798
+ if (err.message && err.message.includes('empty required')) throw err;
799
+ }
800
+ }
801
+
802
+ // Capture state before click to verify it actually did something
803
+ const urlBefore = this.page.url();
804
+ const titleBefore = await this.page.title();
805
+
806
+ try {
807
+ await el.click({ timeout: 5000 });
808
+ } catch (err) {
809
+ await el.click({ timeout: 3000, force: true });
810
+ }
811
+ await this._waitForStable();
812
+
813
+ // Check if click did anything visible
814
+ const urlAfter = this.page.url();
815
+ const titleAfter = await this.page.title();
816
+ const targetLower = (target || '').toLowerCase();
817
+ const looksLikeSubmit = targetLower.includes('login') || targetLower.includes('submit') || targetLower.includes('sign in') || targetLower.includes('continue') || targetLower.includes('next');
818
+
819
+ // If click was on a submit-like button but nothing changed, try submitting the form as a fallback
820
+ if (looksLikeSubmit && urlBefore === urlAfter && titleBefore === titleAfter) {
821
+ try {
822
+ const submitted = await this.page.evaluate(() => {
823
+ const form = document.querySelector('form');
824
+ if (form) {
825
+ // Try native submit first
826
+ if (typeof form.requestSubmit === 'function') {
827
+ form.requestSubmit();
828
+ } else {
829
+ form.submit();
830
+ }
831
+ return true;
832
+ }
833
+ return false;
834
+ });
835
+ if (submitted) {
836
+ await this._waitForStable();
837
+ }
838
+ } catch {}
839
+
840
+ // Also try pressing Enter on the password field as another fallback
841
+ const stillSamePage = this.page.url() === urlBefore;
842
+ if (stillSamePage) {
843
+ try {
844
+ const passwordField = await this.page.locator('input[type="password"]').first();
845
+ if (await passwordField.count() > 0) {
846
+ await passwordField.focus();
847
+ await this.page.keyboard.press('Enter');
848
+ await this._waitForStable();
849
+ }
850
+ } catch {}
851
+ }
852
+ }
853
+
854
+ return { success: true };
855
+ }
856
+ case 'TYPE': {
857
+ let el = null;
858
+ const directTries = [
859
+ () => this.page.locator('input[id="' + target.replace('#','') + '"]').first(),
860
+ () => this.page.locator(target).first(),
861
+ () => this.page.getByPlaceholder(target).first(),
862
+ () => this.page.getByLabel(target).first(),
863
+ () => this.page.locator('input, textarea').filter({ hasText: target }).first(),
864
+ () => this.page.locator('input[name="' + target + '"]').first(),
865
+ ];
866
+ for (const t of directTries) {
867
+ try {
868
+ const candidate = t();
869
+ if (await candidate.count() > 0 && await candidate.isVisible().catch(() => false)) {
870
+ el = candidate;
871
+ break;
872
+ }
873
+ } catch { continue; }
874
+ }
875
+ if (!el) el = await this._resolveElement(target);
876
+ if (!el) throw new Error('Element not found: ' + target);
877
+ // Use focus+type instead of fill to trigger framework input events properly
878
+ await el.click({ force: true });
879
+ await this.page.waitForTimeout(50);
880
+ await el.fill('');
881
+ const expectedValue = value || '';
882
+ await el.type(expectedValue, { delay: 20 });
883
+ await this.page.waitForTimeout(150);
884
+
885
+ // Change 3: verify value actually landed. Some apps swallow input events;
886
+ // we want to know NOW rather than discovering it later via a failed login.
887
+ // We skip the check for password fields and any element that doesn't expose .inputValue()
888
+ let elementType = null;
889
+ try { elementType = (await el.getAttribute('type')) || null; } catch {}
890
+ const isPasswordField = elementType === 'password';
891
+ if (!isPasswordField && expectedValue.length > 0) {
892
+ let actualValue = null;
893
+ try { actualValue = await el.inputValue({ timeout: 500 }); } catch {}
894
+ if (actualValue !== null && actualValue !== expectedValue) {
895
+ // Retry once with a longer delay and a fill() instead of type()
896
+ try { await el.fill(''); } catch {}
897
+ try { await el.fill(expectedValue); } catch {}
898
+ await this.page.waitForTimeout(200);
899
+ let retryValue = null;
900
+ try { retryValue = await el.inputValue({ timeout: 500 }); } catch {}
901
+ if (retryValue !== null && retryValue !== expectedValue) {
902
+ throw new Error('TYPE verification failed for ' + target + ': expected "' + expectedValue.slice(0,30) + '" but field contains "' + (retryValue || '').slice(0,30) + '"');
903
+ }
904
+ }
905
+ }
906
+ return { success: true };
907
+ }
908
+ case 'SELECT': {
909
+ const el = await this._resolveElement(target);
910
+ if (!el) throw new Error('Element not found: ' + target);
911
+ await el.selectOption(value || '');
912
+ await this.page.waitForTimeout(200);
913
+ return { success: true };
914
+ }
915
+ case 'PRESS': {
916
+ await this.page.keyboard.press(value || 'Enter');
917
+ await this._waitForStable();
918
+ return { success: true };
919
+ }
920
+ case 'SCROLL': {
921
+ const direction = (value || 'down').toLowerCase();
922
+ await this.page.evaluate((dir) => { window.scrollBy(0, dir === 'down' ? 400 : -400); }, direction);
923
+ await this.page.waitForTimeout(200);
924
+ return { success: true };
925
+ }
926
+ case 'NAVIGATE': {
927
+ const url = value || target;
928
+ await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
929
+ await this._waitForStable();
930
+ this._cachedDOM = null;
931
+ return { success: true };
932
+ }
933
+ case 'WAIT': {
934
+ const ms = parseInt(value) || 2000;
935
+ await this.page.waitForTimeout(Math.min(ms, 5000));
936
+ return { success: true };
937
+ }
938
+ case 'HOVER': {
939
+ const el = await this._resolveElement(target);
940
+ if (!el) throw new Error('Element not found: ' + target);
941
+ await el.hover();
942
+ await this.page.waitForTimeout(400);
943
+ this._cachedDOM = null; // hover may reveal tooltips
944
+ return { success: true };
945
+ }
946
+ case 'CLICK_AT': {
947
+ const [cx, cy] = (target || value || '').split(',').map(Number);
948
+ if (!isNaN(cx) && !isNaN(cy)) {
949
+ await this.page.mouse.click(cx, cy);
950
+ await this._waitForStable();
951
+ return { success: true };
952
+ }
953
+ throw new Error('CLICK_AT requires x,y coordinates');
954
+ }
955
+ case 'STOP':
956
+ case 'OBSERVE':
957
+ return { success: true };
958
+ default:
959
+ return { success: false, error: 'Unknown action: ' + action };
960
+ }
961
+ } catch (err) {
962
+ return { success: false, error: err.message };
963
+ }
964
+ }
965
+
966
+ async _resolveElement(target) {
967
+ if (!target) return null;
968
+
969
+ // Parse "button:Login" or "link:Submit" style targets
970
+ let parsedRole = null;
971
+ let parsedName = target;
972
+ const roleMatch = target.match(/^(button|link|input|menuitem|tab):(.+)$/i);
973
+ if (roleMatch) {
974
+ parsedRole = roleMatch[1].toLowerCase();
975
+ parsedName = roleMatch[2].trim();
976
+ }
977
+
978
+ const strategies = [
979
+ // If parsed role:name format, try those first
980
+ ...(parsedRole ? [
981
+ () => this.page.getByRole(parsedRole, { name: parsedName }).first(),
982
+ () => this.page.getByText(parsedName, { exact: false }).first(),
983
+ ] : []),
984
+ // Direct CSS selector
985
+ () => this.page.locator(target).first(),
986
+ // Text matches
987
+ () => this.page.getByText(target, { exact: true }).first(),
988
+ () => this.page.getByText(target, { exact: false }).first(),
989
+ // Labels/placeholders
990
+ () => this.page.getByLabel(target).first(),
991
+ () => this.page.getByPlaceholder(target).first(),
992
+ // Role-based
993
+ () => this.page.getByRole('button', { name: target }).first(),
994
+ () => this.page.getByRole('link', { name: target }).first(),
995
+ // Attributes
996
+ () => this.page.locator('[title="' + target + '"]').first(),
997
+ () => this.page.locator('[title*="' + target + '"]').first(),
998
+ () => this.page.locator('[aria-label*="' + target + '"]').first(),
999
+ // Class fragment match (case-insensitive)
1000
+ () => this.page.locator('[class*="' + target.toLowerCase() + '"]').first(),
1001
+ // Look for clickable parent of an element with this text
1002
+ () => this.page.locator('button, [role="button"], a, [tabindex]').filter({ hasText: target }).first(),
1003
+ // Custom widget pattern: any DIV/SPAN that contains exactly this text and looks like a list/menu item
1004
+ () => this.page.locator('div, span').filter({ hasText: new RegExp('^\\s*' + (target||'').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + '\\s*$') }).locator('xpath=ancestor-or-self::*[contains(@class, "list-group-item") or contains(@class, "collapsible") or contains(@class, "category") or contains(@class, "menu-item") or contains(@class, "nav-item")][1]').first(),
1005
+ // Generic: clickable ancestor (div with onclick handler, role=button, etc.) of an exact-text match
1006
+ () => this.page.locator('span, i, em').filter({ hasText: target }).locator('xpath=ancestor::*[@onclick or @ng-click or self::a or self::button or @role="button"][1]').first(),
1007
+ // Common login button patterns
1008
+ ...(target.toLowerCase().includes('login') || target.toLowerCase().includes('submit') || target.toLowerCase().includes('sign in') ? [
1009
+ () => this.page.locator('button[type="submit"]').first(),
1010
+ () => this.page.locator('[class*="login"], [class*="submit"], [class*="sign-in"]').first(),
1011
+ () => this.page.locator('paper-button, [class*="paper-button"]').first(),
1012
+ ] : []),
1013
+ ];
1014
+
1015
+ for (const strategy of strategies) {
1016
+ try {
1017
+ const el = strategy();
1018
+ const count = await el.count();
1019
+ if (count > 0) {
1020
+ const visible = await el.isVisible().catch(() => false);
1021
+ if (visible) return el;
1022
+ }
1023
+ } catch {
1024
+ continue;
1025
+ }
1026
+ }
1027
+
1028
+ if (/^\d+,\d+$/.test(target)) {
1029
+ const [x, y] = target.split(',').map(Number);
1030
+ return { click: async () => this.page.mouse.click(x, y), hover: async () => this.page.mouse.move(x, y) };
1031
+ }
1032
+
1033
+ return null;
1034
+ }
1035
+
1036
+ async close() {
1037
+ let videoPath = null;
1038
+ try {
1039
+ if (this.page) {
1040
+ const video = this.page.video();
1041
+ if (video) videoPath = await video.path().catch(() => null);
1042
+ await this.page.close();
1043
+ }
1044
+ if (this.context) await this.context.close();
1045
+ if (this.browser) await this.browser.close();
1046
+ } catch {}
1047
+ return videoPath;
1048
+ }
1049
+ }