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.
- package/.dockerignore +65 -0
- package/.github/workflows/docker.yml +78 -0
- package/cli/commands/agent.js +378 -0
- package/cli/commands/config.js +67 -0
- package/cli/commands/dashboard.js +3524 -0
- package/cli/commands/init.js +190 -0
- package/cli/commands/report.js +41 -0
- package/cli/commands/run.js +350 -0
- package/cli/index.js +85 -0
- package/cli/ui.js +126 -0
- package/core/auth.js +148 -0
- package/core/browser.js +1049 -0
- package/core/credentials.js +47 -0
- package/core/db.js +503 -0
- package/core/llm.js +641 -0
- package/core/recorder.js +653 -0
- package/core/reporter.js +282 -0
- package/core/tracker.js +768 -0
- package/package.json +54 -0
- package/web/app/index.html +5937 -0
- package/web/index.html +644 -0
- package/web/invite.html +244 -0
- package/web/login.html +271 -0
- package/web/reset.html +222 -0
- package/web/setup.html +300 -0
package/core/browser.js
ADDED
|
@@ -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
|
+
}
|