vision-navigator 1.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/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Vision Navigator
2
+
3
+ A raw CDP (Chrome DevTools Protocol) browser automation tool powered by local LLMs (Ollama) or OpenRouter.
4
+ It natively handles navigation, simplified DOM extraction, and browser interaction via a custom CDP driver, avoiding heavy dependencies like Puppeteer or Playwright.
5
+
6
+ ## Features
7
+ - **Custom CDP Driver**: Communicates directly with Chromium over WebSockets.
8
+ - **Local AI Powered**: Uses `qwen2.5:3b` via Ollama by default, capable of running entirely locally.
9
+ - **Web UI Dashboard**: Includes a sleek dashboard to submit YAML workflows and view step-by-step results, screenshots, and logs.
10
+ - **AI Diagnostics**: Automatically monitors console errors, network issues, and performance metrics, and uses the AI to provide usability/performance suggestions.
11
+ - **CLI Interface**: Can be run as an NPM package/CLI to execute workflows headlessly and get a pass/fail report.
12
+
13
+ ## 🚀 How to Run (3 Ways)
14
+
15
+ Vision Navigator is flexible and can be run in three different modes depending on your needs.
16
+
17
+ ### Environment Configuration (.env)
18
+
19
+ You can configure the model, LLM provider, and storage settings using environment variables. Create a `.env` file in the root of the `vision_navigator_ts` directory (or wherever you are running the CLI from):
20
+
21
+ ```env
22
+ # Default is qwen2.5:3b via local Ollama
23
+ OLLAMA_URL=http://localhost:11434
24
+ MODEL=qwen2.5:3b
25
+
26
+ # If using OpenRouter instead of local Ollama
27
+ OPENROUTER_API_KEY=your_api_key_here
28
+ OPENROUTER_MODEL=google/gemini-2.0-flash-exp:free
29
+
30
+ # Storage configurations (optional)
31
+ POCKETBASE_URL=http://127.0.0.1:8091
32
+ MINIO_ENDPOINT=127.0.0.1
33
+ MINIO_PORT=9002
34
+ MINIO_ACCESS_KEY=minioadmin
35
+ MINIO_SECRET_KEY=minioadmin
36
+ MINIO_BUCKET=store-runs
37
+ ```
38
+
39
+ ### 1. As a Standalone NPM CLI
40
+
41
+ If you just want to run tests locally via your terminal, you can install and use it as an NPM package.
42
+
43
+ ```bash
44
+ # Install dependencies and build
45
+ npm install
46
+ npm run build
47
+
48
+ # Link the package globally
49
+ npm link
50
+
51
+ # Run a single workflow
52
+ vision-navigator run ./workflows/riskely-test.yaml
53
+
54
+ # Run a directory of tests
55
+ vision-navigator test ./workflows
56
+
57
+ # Start the built-in web server (runs on port 8000)
58
+ vision-navigator serve
59
+ ```
60
+
61
+ ### 2. Standalone Docker Container
62
+
63
+ If you want an isolated environment without installing Node.js or Chromium on your host machine, you can run the tool as a standalone Docker container.
64
+
65
+ ```bash
66
+ # Build the Docker image
67
+ docker build -t vision-navigator .
68
+
69
+ # Run a single workflow (mount your local workflows directory)
70
+ docker run --rm -v $(pwd)/workflows:/usr/src/app/workflows vision-navigator npm run start -- run workflows/riskely-test.yaml
71
+
72
+ # Start the Web UI only
73
+ docker run -p 8000:8000 vision-navigator
74
+ ```
75
+ *Note: If you use a local Ollama instance on your host, make sure to pass the correct `OLLAMA_URL` environment variable (e.g. `-e OLLAMA_URL=http://host.docker.internal:11434`).*
76
+
77
+ ### 3. Full Stack with Docker Compose (Recommended for Self-Hosting)
78
+
79
+ The most comprehensive way to run Vision Navigator. This spins up the full environment including the main server, a dedicated **Ollama** container (pre-configured with `qwen2.5:3b`), **PocketBase** (for test run history), and **Minio** (for storing screenshot artifacts).
80
+
81
+ 1. Ensure you have Docker and Docker Compose installed.
82
+ 2. Run the following command in the root directory:
83
+
84
+ ```bash
85
+ docker-compose up -d --build
86
+ ```
87
+
88
+ This will automatically:
89
+ - Start **Vision Navigator** Web UI at `http://localhost:8000`
90
+ - Start **PocketBase** at `http://localhost:8091`
91
+ - Start **Minio** at `http://localhost:9002`
92
+ - Start **Ollama** and automatically pull the `qwen2.5:3b` model.
93
+
94
+ **Running CLI tests within Docker Compose:**
95
+ ```bash
96
+ # Run a single workflow
97
+ docker exec -it vision-navigator npm run start -- run workflows/riskely-test.yaml
98
+
99
+ # Run all workflows in a directory
100
+ docker exec -it vision-navigator npm run start -- test workflows
101
+ ```
102
+
103
+ ## 📝 Writing Workflows
104
+ Workflows are written in simple YAML format.
105
+
106
+ ```yaml
107
+ steps:
108
+ - instruction: "Navigate to http://quotes.toscrape.com/"
109
+ - instruction: "Click the 'Login' link near the top right"
110
+ - instruction: "Type 'admin' into the username field"
111
+ - instruction: "Type 'password123' into the password field"
112
+ - instruction: "Click the Login button"
113
+ ```
@@ -0,0 +1,484 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CDPDriver = void 0;
7
+ const child_process_1 = require("child_process");
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const ws_1 = __importDefault(require("ws"));
10
+ const injected_scripts_1 = require("./injected-scripts");
11
+ const os_1 = __importDefault(require("os"));
12
+ class CDPDriver {
13
+ constructor(port = 9222) {
14
+ this.chromeProcess = null;
15
+ this.ws = null;
16
+ this.logs = [];
17
+ this.networkIssues = [];
18
+ this.performanceMetrics = null;
19
+ this.messageId = 1;
20
+ this.pendingRequests = new Map();
21
+ this.eventListeners = new Map();
22
+ this.port = port;
23
+ }
24
+ getChromePath() {
25
+ if (process.env.CHROME_BIN)
26
+ return process.env.CHROME_BIN;
27
+ const platform = os_1.default.platform();
28
+ if (platform === 'darwin')
29
+ return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
30
+ if (platform === 'win32')
31
+ return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
32
+ return '/usr/bin/google-chrome'; // Linux
33
+ }
34
+ async start() {
35
+ console.log("Starting custom CDP driver without Puppeteer...");
36
+ const chromePath = this.getChromePath();
37
+ this.chromeProcess = (0, child_process_1.spawn)(chromePath, [
38
+ `--remote-debugging-port=${this.port}`,
39
+ '--headless=new', // Modern headless fixes blank screenshot issues
40
+ '--no-sandbox',
41
+ '--disable-dev-shm-usage',
42
+ '--window-size=1280,800',
43
+ '--disable-web-security',
44
+ '--ignore-certificate-errors'
45
+ ]);
46
+ // Wait for Chrome to start CDP server
47
+ let wsUrl = '';
48
+ for (let i = 0; i < 20; i++) {
49
+ try {
50
+ await new Promise(r => setTimeout(r, 500));
51
+ const res = await axios_1.default.get(`http://127.0.0.1:${this.port}/json`);
52
+ const page = res.data.find((t) => t.type === 'page');
53
+ if (page && page.webSocketDebuggerUrl) {
54
+ wsUrl = page.webSocketDebuggerUrl;
55
+ break;
56
+ }
57
+ }
58
+ catch (e) {
59
+ // Ignore and retry
60
+ }
61
+ }
62
+ if (!wsUrl) {
63
+ this.stop();
64
+ throw new Error("Failed to connect to Chrome CDP");
65
+ }
66
+ console.log(`Connected to native CDP WebSocket: ${wsUrl}`);
67
+ await this.connectWs(wsUrl);
68
+ // Enable domains
69
+ await this.send('Page.enable');
70
+ await this.send('Runtime.enable');
71
+ await this.send('Log.enable');
72
+ await this.send('Network.enable');
73
+ await this.send('Performance.enable');
74
+ // Listen to console events
75
+ this.on('Runtime.consoleAPICalled', (params) => {
76
+ const type = params.type.toUpperCase();
77
+ if (['WARNING', 'ERROR'].includes(type) || type === 'WARN') {
78
+ const args = params.args.map((a) => a.value || a.description).join(' ');
79
+ this.logs.push(`[CONSOLE ${type}] ${args}`);
80
+ }
81
+ });
82
+ this.on('Runtime.exceptionThrown', (params) => {
83
+ this.logs.push(`[CONSOLE EXCEPTION] ${params.exceptionDetails.text}`);
84
+ });
85
+ this.on('Network.requestFailed', (params) => {
86
+ const { request, errorText } = params;
87
+ this.networkIssues.push(`[NETWORK FAILED] ${request?.url || params.requestId} - ${errorText}`);
88
+ });
89
+ this.on('Network.responseReceived', (params) => {
90
+ const { response } = params;
91
+ if (response && response.status >= 400) {
92
+ this.networkIssues.push(`[NETWORK ${response.status}] ${response.url}`);
93
+ }
94
+ });
95
+ }
96
+ connectWs(url) {
97
+ return new Promise((resolve, reject) => {
98
+ this.ws = new ws_1.default(url);
99
+ this.ws.on('open', resolve);
100
+ this.ws.on('error', reject);
101
+ this.ws.on('message', (data) => {
102
+ const msg = JSON.parse(data.toString());
103
+ if (msg.id) {
104
+ const cb = this.pendingRequests.get(msg.id);
105
+ if (cb) {
106
+ if (msg.error)
107
+ cb.reject(new Error(msg.error.message));
108
+ else
109
+ cb.resolve(msg.result);
110
+ this.pendingRequests.delete(msg.id);
111
+ }
112
+ }
113
+ else if (msg.method) {
114
+ const listeners = this.eventListeners.get(msg.method) || [];
115
+ listeners.forEach(fn => fn(msg.params));
116
+ }
117
+ });
118
+ });
119
+ }
120
+ send(method, params = {}) {
121
+ return new Promise((resolve, reject) => {
122
+ if (!this.ws)
123
+ return reject(new Error("No WS connection"));
124
+ const id = this.messageId++;
125
+ this.pendingRequests.set(id, { resolve, reject });
126
+ this.ws.send(JSON.stringify({ id, method, params }));
127
+ });
128
+ }
129
+ on(method, callback) {
130
+ const listeners = this.eventListeners.get(method) || [];
131
+ listeners.push(callback);
132
+ this.eventListeners.set(method, listeners);
133
+ }
134
+ once(method) {
135
+ return new Promise(resolve => {
136
+ const cb = (params) => {
137
+ resolve(params);
138
+ const listeners = this.eventListeners.get(method) || [];
139
+ this.eventListeners.set(method, listeners.filter(fn => fn !== cb));
140
+ };
141
+ this.on(method, cb);
142
+ });
143
+ }
144
+ async stop() {
145
+ if (this.ws)
146
+ this.ws.close();
147
+ if (this.chromeProcess) {
148
+ this.chromeProcess.kill();
149
+ }
150
+ }
151
+ getLogs() {
152
+ const logs = [...this.logs];
153
+ this.logs = [];
154
+ return logs;
155
+ }
156
+ getNetworkIssues() {
157
+ const issues = [...this.networkIssues];
158
+ this.networkIssues = [];
159
+ return issues;
160
+ }
161
+ async getPerformanceMetrics() {
162
+ try {
163
+ const metrics = await this.send('Performance.getMetrics');
164
+ return metrics.metrics.reduce((acc, m) => {
165
+ acc[m.name] = m.value;
166
+ return acc;
167
+ }, {});
168
+ }
169
+ catch (e) {
170
+ return {};
171
+ }
172
+ }
173
+ async executeScript(script, args = []) {
174
+ let expression = script;
175
+ if (args.length > 0) {
176
+ expression = `(${script})(...${JSON.stringify(args)})`;
177
+ }
178
+ const res = await this.send('Runtime.evaluate', {
179
+ expression: expression,
180
+ returnByValue: true,
181
+ awaitPromise: true
182
+ });
183
+ if (res.exceptionDetails) {
184
+ throw new Error(`Script error: ${res.exceptionDetails.exception?.description || res.exceptionDetails.text}`);
185
+ }
186
+ return res.result.value;
187
+ }
188
+ async navigate(url) {
189
+ console.log(`Navigating to ${url}...`);
190
+ const navPromise = this.once('Page.loadEventFired');
191
+ await this.send('Page.navigate', { url });
192
+ await navPromise;
193
+ // Add a small delay after navigation to ensure initial render is complete
194
+ await new Promise(r => setTimeout(r, 1000));
195
+ const res = await this.executeScript(`document.title`);
196
+ console.log(`Page loaded. Title: ${res}`);
197
+ }
198
+ async getScreenshot() {
199
+ await new Promise(r => setTimeout(r, 500));
200
+ const res = await this.send('Page.captureScreenshot', { format: 'png' });
201
+ return res.data;
202
+ }
203
+ async click(x, y) {
204
+ await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
205
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
206
+ }
207
+ async typeText(text) {
208
+ for (const char of text) {
209
+ await this.send('Input.dispatchKeyEvent', { type: 'char', text: char });
210
+ }
211
+ }
212
+ async scroll(direction) {
213
+ const yDelta = direction === "down" ? 500 : -500;
214
+ await this.executeScript(`window.scrollBy(0, ${yDelta})`);
215
+ await new Promise(r => setTimeout(r, 500));
216
+ }
217
+ async getSimplifiedDOM() {
218
+ const result = await this.executeScript(injected_scripts_1.PAGE_AGENT_SCRIPT);
219
+ if (result && result.tree) {
220
+ return this.formatDOMTree(result.tree);
221
+ }
222
+ return "<body></body>";
223
+ }
224
+ formatDOMTree(node, depth = 0) {
225
+ if (!node)
226
+ return '';
227
+ if (node.type === 'TEXT_NODE') {
228
+ const text = node.text.replace(/\s+/g, ' ').trim();
229
+ return text ? (text.length > 50 ? text.substring(0, 50) + '...' : text) + ' ' : '';
230
+ }
231
+ const tagName = node.tagName;
232
+ let attributes = '';
233
+ if (node.isInteractive && node.highlightIndex !== null) {
234
+ attributes += ` id="${node.highlightIndex}"`;
235
+ }
236
+ const importantAttrs = ['placeholder', 'aria-label', 'title', 'name', 'value', 'alt'];
237
+ if (node.attributes) {
238
+ for (const [key, val] of Object.entries(node.attributes)) {
239
+ if (importantAttrs.includes(key) && val) {
240
+ const valStr = String(val);
241
+ const truncated = valStr.length > 30 ? valStr.substring(0, 30) + '...' : valStr;
242
+ attributes += ` ${key}="${truncated}"`;
243
+ }
244
+ }
245
+ }
246
+ let childrenHTML = '';
247
+ if (node.children && node.children.length > 0) {
248
+ for (const child of node.children) {
249
+ childrenHTML += this.formatDOMTree(child, depth + 1);
250
+ }
251
+ }
252
+ const hasContent = childrenHTML.trim().length > 0;
253
+ if (!node.isInteractive && !hasContent) {
254
+ return '';
255
+ }
256
+ if (node.isInteractive) {
257
+ if (hasContent) {
258
+ return `<${tagName}${attributes}>${childrenHTML}</${tagName}>`;
259
+ }
260
+ else {
261
+ return `<${tagName}${attributes}/>`;
262
+ }
263
+ }
264
+ else {
265
+ return childrenHTML + ' ';
266
+ }
267
+ }
268
+ async clickById(id) {
269
+ console.log(`Clicking element with ID: ${id}`);
270
+ await this.executeScript(`
271
+ (function() {
272
+ const el = document.querySelector('[data-vnav-id="${id}"]');
273
+ if (el) {
274
+ // Visual feedback
275
+ const rect = el.getBoundingClientRect();
276
+ const overlay = document.createElement('div');
277
+ Object.assign(overlay.style, {
278
+ position: 'fixed', left: rect.left + 'px', top: rect.top + 'px',
279
+ width: rect.width + 'px', height: rect.height + 'px',
280
+ backgroundColor: 'rgba(255, 0, 0, 0.3)', border: '2px solid red',
281
+ zIndex: '2147483647', pointerEvents: 'none'
282
+ });
283
+ document.body.appendChild(overlay);
284
+ setTimeout(() => overlay.remove(), 500);
285
+
286
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
287
+ el.click();
288
+ }
289
+ })();
290
+ `);
291
+ await new Promise(r => setTimeout(r, 600));
292
+ }
293
+ async typeById(id, text) {
294
+ console.log(`Typing "${text}" into element with ID: ${id}`);
295
+ await this.executeScript(`
296
+ (function() {
297
+ const el = document.querySelector('[data-vnav-id="${id}"]');
298
+ if (el) {
299
+ // Visual feedback
300
+ const rect = el.getBoundingClientRect();
301
+ const overlay = document.createElement('div');
302
+ Object.assign(overlay.style, {
303
+ position: 'fixed', left: rect.left + 'px', top: rect.top + 'px',
304
+ width: rect.width + 'px', height: rect.height + 'px',
305
+ backgroundColor: 'rgba(0, 0, 255, 0.3)', border: '2px solid blue',
306
+ zIndex: '2147483647', pointerEvents: 'none'
307
+ });
308
+ document.body.appendChild(overlay);
309
+ setTimeout(() => overlay.remove(), 500);
310
+
311
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
312
+ el.focus();
313
+
314
+ if (el.isContentEditable) {
315
+ if (!document.execCommand('insertText', false, ${JSON.stringify(text)})) {
316
+ el.innerText = ${JSON.stringify(text)};
317
+ }
318
+ } else {
319
+ // React-friendly value setter
320
+ const proto = el instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
321
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
322
+ if (nativeInputValueSetter) {
323
+ nativeInputValueSetter.call(el, ${JSON.stringify(text)});
324
+ } else {
325
+ el.value = ${JSON.stringify(text)};
326
+ }
327
+ }
328
+
329
+ el.dispatchEvent(new Event('input', { bubbles: true }));
330
+ el.dispatchEvent(new Event('change', { bubbles: true }));
331
+ }
332
+ })();
333
+ `);
334
+ await new Promise(r => setTimeout(r, 600));
335
+ }
336
+ async dismissCookieBanners(opts) {
337
+ try {
338
+ const aggressive = Boolean(opts?.aggressive);
339
+ const res = await this.executeScript(`
340
+ (() => {
341
+ const aggressive = ${JSON.stringify(aggressive)};
342
+ const COOKIE_WORDS = [
343
+ 'cookie', 'cookies', 'kaka', 'kakor', 'samtycke', 'samtyck', 'consent', 'gdpr', 'integritet', 'privacy'
344
+ ];
345
+ const ACCEPT = [
346
+ 'accept', 'accept all', 'agree', 'i agree', 'allow', 'allow all', 'ok', 'okay',
347
+ 'acceptera', 'acceptera alla', 'godkänn', 'godkänn alla', 'tillåt', 'tillåt alla', 'jag godkänner', 'jag accepterar',
348
+ 'samtyck', 'samtycker'
349
+ ];
350
+ const DENY = [
351
+ 'reject', 'decline', 'deny', 'no thanks', 'manage', 'settings', 'preferences', 'customize', 'only necessary',
352
+ 'avvisa', 'neka', 'inställ', 'inställningar', 'anpassa', 'nödvänd', 'bara nödvändiga', 'hantera', 'preferenser'
353
+ ];
354
+
355
+ const normalize = (s) => (s || '')
356
+ .toString()
357
+ .toLowerCase()
358
+ .normalize('NFD')
359
+ .replace(/[\\u0300-\\u036f]/g, '')
360
+ .replace(/\\s+/g, ' ')
361
+ .trim();
362
+
363
+ const isVisible = (el) => {
364
+ try {
365
+ const style = window.getComputedStyle(el);
366
+ if (!style || style.visibility === 'hidden' || style.display === 'none') return false;
367
+ const rect = el.getBoundingClientRect();
368
+ if (!rect || rect.width < 2 || rect.height < 2) return false;
369
+ if (rect.bottom < 0 || rect.right < 0) return false;
370
+ return true;
371
+ } catch {
372
+ return false;
373
+ }
374
+ };
375
+
376
+ const hasCookieContext = (el) => {
377
+ try {
378
+ const bodyText = normalize(document.body?.innerText || document.body?.textContent || '');
379
+ const globalCookie = COOKIE_WORDS.some(w => bodyText.includes(normalize(w)));
380
+ if (globalCookie) return true;
381
+ let node = el;
382
+ for (let i = 0; i < 6 && node; i++) {
383
+ const t = normalize(node.innerText || node.textContent || '');
384
+ if (COOKIE_WORDS.some(w => t.includes(normalize(w)))) return true;
385
+ node = node.parentElement;
386
+ }
387
+ return false;
388
+ } catch {
389
+ return false;
390
+ }
391
+ };
392
+
393
+ const scoreEl = (el) => {
394
+ const text = normalize(el.innerText || el.textContent || '');
395
+ const label = normalize(el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('value') || '');
396
+ const combined = (text + ' ' + label).trim();
397
+ if (!combined) return { score: 0, combined };
398
+ for (const bad of DENY) {
399
+ if (combined.includes(normalize(bad))) return { score: 0, combined };
400
+ }
401
+ let score = 0;
402
+ for (const ok of ACCEPT) {
403
+ const tok = normalize(ok);
404
+ if (!tok) continue;
405
+ if (combined === tok) score += 50;
406
+ else if (combined.includes(tok)) score += 20;
407
+ }
408
+ if (!aggressive && !hasCookieContext(el)) score = 0;
409
+ const tag = (el.tagName || '').toLowerCase();
410
+ if (tag === 'button') score += 5;
411
+ const role = normalize(el.getAttribute('role'));
412
+ if (role === 'button') score += 2;
413
+ return { score, combined };
414
+ };
415
+
416
+ const knownSelectors = [
417
+ '#onetrust-accept-btn-handler',
418
+ '#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
419
+ '#CybotCookiebotDialogBodyButtonAccept',
420
+ 'button[id*="cookie"][id*="accept"]',
421
+ 'button[id*="consent"][id*="accept"]',
422
+ 'button[data-testid*="cookie"][data-testid*="accept"]'
423
+ ];
424
+
425
+ const clickFirst = (el) => {
426
+ try {
427
+ el.scrollIntoView({ block: 'center', inline: 'center' });
428
+ el.click();
429
+ return true;
430
+ } catch {
431
+ return false;
432
+ }
433
+ };
434
+
435
+ for (const sel of knownSelectors) {
436
+ const el = document.querySelector(sel);
437
+ if (el && isVisible(el)) {
438
+ const meta = scoreEl(el);
439
+ if (clickFirst(el)) {
440
+ return { clicked: true, label: meta.combined || sel, candidates: 1 };
441
+ }
442
+ }
443
+ }
444
+
445
+ const collect = (root, out) => {
446
+ try {
447
+ const nodes = root.querySelectorAll('button, a, input[type="button"], input[type="submit"], [role="button"]');
448
+ nodes.forEach(n => out.push(n));
449
+ const all = root.querySelectorAll('*');
450
+ all.forEach(n => {
451
+ if (n && n.shadowRoot) collect(n.shadowRoot, out);
452
+ });
453
+ } catch {}
454
+ };
455
+
456
+ const candidates = [];
457
+ collect(document, candidates);
458
+
459
+ const scored = candidates
460
+ .filter(isVisible)
461
+ .map(el => ({ el, ...scoreEl(el) }))
462
+ .filter(x => x.score > 0)
463
+ .sort((a, b) => b.score - a.score);
464
+
465
+ if (scored.length === 0) return { clicked: false, candidates: 0 };
466
+
467
+ const best = scored[0];
468
+ const ok = clickFirst(best.el);
469
+ return { clicked: !!ok, label: best.combined, candidates: scored.length };
470
+ })()
471
+ `);
472
+ return { clicked: Boolean(res?.clicked), label: res?.label, candidates: res?.candidates };
473
+ }
474
+ catch {
475
+ return { clicked: false };
476
+ }
477
+ }
478
+ // Keep legacy method signature if strictly needed, or reimplement
479
+ async getInteractiveElements() {
480
+ // Not strictly used by Navigator currently, but good to have
481
+ return [];
482
+ }
483
+ }
484
+ exports.CDPDriver = CDPDriver;