@restregular/dsh-visual-browser 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/browser.js ADDED
@@ -0,0 +1,963 @@
1
+ /**
2
+ * BrowserManager owns exactly one Chrome process and one page target.
3
+ *
4
+ * The manager is the only place that knows about the DevTools Protocol; the
5
+ * agent tools and the panel routes both drive this same object, so a click from
6
+ * the model and a click from the user follow one code path.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, mkdirSync } from 'node:fs';
11
+ import { writeFile } from 'node:fs/promises';
12
+ import { homedir, tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { CdpConnection } from './cdp.js';
16
+ import {
17
+ pageFactsExpression,
18
+ prepareTypingExpression,
19
+ readTextExpression,
20
+ resolveExpression,
21
+ selectOptionExpression,
22
+ snapshotExpression,
23
+ } from './page-scripts.js';
24
+
25
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26
+
27
+ export class BrowserError extends Error {
28
+ constructor(code, message, detail) {
29
+ super(message);
30
+ this.name = 'BrowserError';
31
+ this.code = code;
32
+ this.detail = detail;
33
+ }
34
+ }
35
+
36
+ export const DEFAULT_CONFIG = {
37
+ headless: true,
38
+ executablePath: '',
39
+ userDataDir: '',
40
+ viewportWidth: 1280,
41
+ viewportHeight: 800,
42
+ maxElements: 100,
43
+ maxTextChars: 320,
44
+ maxNameChars: 64,
45
+ frameQuality: 62,
46
+ timelineFrames: 24,
47
+ allowUserControl: true,
48
+ actionTimeoutMs: 12_000,
49
+ navigationTimeoutMs: 30_000,
50
+ startupTimeoutMs: 30_000,
51
+ };
52
+
53
+ /** Normalize the loose Loader row config into a complete settings object. */
54
+ export function normalizeConfig(input = {}) {
55
+ const source = input && typeof input === 'object' ? input : {};
56
+ const viewport = source.viewport && typeof source.viewport === 'object' ? source.viewport : {};
57
+ const clamp = (value, min, max, fallback) => {
58
+ const number = Number(value);
59
+ if (!Number.isFinite(number)) return fallback;
60
+ return Math.min(max, Math.max(min, Math.round(number)));
61
+ };
62
+ return {
63
+ ...DEFAULT_CONFIG,
64
+ ...source,
65
+ headless: source.headless === undefined ? DEFAULT_CONFIG.headless : source.headless !== false,
66
+ executablePath: typeof source.executablePath === 'string' ? source.executablePath : '',
67
+ userDataDir: typeof source.userDataDir === 'string' ? source.userDataDir : '',
68
+ viewportWidth: clamp(viewport.width ?? source.viewportWidth, 320, 4096, DEFAULT_CONFIG.viewportWidth),
69
+ viewportHeight: clamp(viewport.height ?? source.viewportHeight, 240, 4096, DEFAULT_CONFIG.viewportHeight),
70
+ maxElements: clamp(source.maxElements, 10, 400, DEFAULT_CONFIG.maxElements),
71
+ maxTextChars: clamp(source.maxTextChars, 40, 4000, DEFAULT_CONFIG.maxTextChars),
72
+ maxNameChars: clamp(source.maxNameChars, 16, 200, DEFAULT_CONFIG.maxNameChars),
73
+ frameQuality: clamp(source.frameQuality, 20, 95, DEFAULT_CONFIG.frameQuality),
74
+ timelineFrames: clamp(source.timelineFrames, 4, 120, DEFAULT_CONFIG.timelineFrames),
75
+ allowUserControl: source.allowUserControl === undefined ? DEFAULT_CONFIG.allowUserControl : source.allowUserControl !== false,
76
+ actionTimeoutMs: clamp(source.actionTimeoutMs, 500, 60_000, DEFAULT_CONFIG.actionTimeoutMs),
77
+ navigationTimeoutMs: clamp(source.navigationTimeoutMs, 1000, 120_000, DEFAULT_CONFIG.navigationTimeoutMs),
78
+ startupTimeoutMs: clamp(source.startupTimeoutMs, 2000, 120_000, DEFAULT_CONFIG.startupTimeoutMs),
79
+ };
80
+ }
81
+
82
+ /** Where the persistent Chrome profile lives (cookies survive host restarts). */
83
+ export function defaultUserDataDir() {
84
+ return path.join(homedir(), '.dsh', 'visual-browser', 'chrome-profile');
85
+ }
86
+
87
+ /** Candidate executables per platform, most preferred first. */
88
+ export function browserCandidates(platform = process.platform, env = process.env) {
89
+ const fromEnv = [env.DSH_VISUAL_BROWSER_PATH, env.CHROME_PATH, env.EDGE_PATH].filter(
90
+ (value) => typeof value === 'string' && value.trim() !== '',
91
+ );
92
+ if (platform === 'win32') {
93
+ const programFiles = [env['PROGRAMFILES'], env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].filter(Boolean);
94
+ const relative = [
95
+ 'Google/Chrome/Application/chrome.exe',
96
+ 'Microsoft/Edge/Application/msedge.exe',
97
+ 'Chromium/Application/chrome.exe',
98
+ 'BraveSoftware/Brave-Browser/Application/brave.exe',
99
+ ];
100
+ const found = [];
101
+ for (const base of programFiles) {
102
+ for (const tail of relative) found.push(path.join(base, tail));
103
+ }
104
+ return [...fromEnv, ...found];
105
+ }
106
+ if (platform === 'darwin') {
107
+ return [
108
+ ...fromEnv,
109
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
110
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
111
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
112
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
113
+ ];
114
+ }
115
+ return [
116
+ ...fromEnv,
117
+ '/usr/bin/google-chrome',
118
+ '/usr/bin/google-chrome-stable',
119
+ '/usr/bin/chromium',
120
+ '/usr/bin/chromium-browser',
121
+ '/usr/bin/microsoft-edge',
122
+ '/snap/bin/chromium',
123
+ ];
124
+ }
125
+
126
+ /** First existing browser executable, or an empty string. */
127
+ export function findBrowserExecutable(config, platform = process.platform, env = process.env) {
128
+ const candidates = [];
129
+ if (config && typeof config.executablePath === 'string' && config.executablePath.trim() !== '') {
130
+ candidates.push(config.executablePath.trim());
131
+ }
132
+ candidates.push(...browserCandidates(platform, env));
133
+ for (const candidate of candidates) {
134
+ try {
135
+ if (existsSync(candidate)) return candidate;
136
+ } catch {
137
+ /* unreadable candidate, keep looking */
138
+ }
139
+ }
140
+ return '';
141
+ }
142
+
143
+ /** Accept `example.com/login`, `localhost:3000`, or a full URL. */
144
+ export function normalizeUrl(input) {
145
+ const raw = String(input ?? '').trim();
146
+ if (raw === '') throw new BrowserError('BAD_URL', 'A URL is required, for example https://example.com');
147
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) return raw;
148
+ if (/^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?(\/|$)/i.test(raw)) return `http://${raw}`;
149
+ if (/^[\w-]+(\.[\w-]+)+(\/|$|:)/.test(raw)) return `https://${raw}`;
150
+ throw new BrowserError('BAD_URL', `"${raw}" does not look like a URL; include the scheme or a domain such as example.com`);
151
+ }
152
+
153
+ const KEY_CODES = {
154
+ Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
155
+ Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9, text: '\t' },
156
+ Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
157
+ Backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
158
+ Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
159
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
160
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
161
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
162
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
163
+ Home: { key: 'Home', code: 'Home', windowsVirtualKeyCode: 36 },
164
+ End: { key: 'End', code: 'End', windowsVirtualKeyCode: 35 },
165
+ PageUp: { key: 'PageUp', code: 'PageUp', windowsVirtualKeyCode: 33 },
166
+ PageDown: { key: 'PageDown', code: 'PageDown', windowsVirtualKeyCode: 34 },
167
+ Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32, text: ' ' },
168
+ };
169
+
170
+ const MODIFIERS = { Alt: 1, Control: 2, Ctrl: 2, Meta: 4, Command: 4, Shift: 8 };
171
+
172
+ /** Translate "Control+a" or "Enter" into one `Input.dispatchKeyEvent` spec. */
173
+ export function keySpec(combo) {
174
+ const raw = String(combo ?? '').trim();
175
+ if (raw === '') throw new BrowserError('BAD_KEY', 'A key is required, for example Enter');
176
+ const parts = raw.split('+').map((part) => part.trim()).filter(Boolean);
177
+ let modifiers = 0;
178
+ let name = '';
179
+ for (const part of parts) {
180
+ if (MODIFIERS[part] !== undefined && parts.length > 1) modifiers |= MODIFIERS[part];
181
+ else name = part;
182
+ }
183
+ if (name === '') name = parts[parts.length - 1];
184
+ const known = KEY_CODES[name] ?? KEY_CODES[name[0].toUpperCase() + name.slice(1)];
185
+ if (known) {
186
+ const spec = { ...known, modifiers };
187
+ if (modifiers !== 0) delete spec.text;
188
+ return spec;
189
+ }
190
+ if (name.length === 1) {
191
+ const upper = name.toUpperCase();
192
+ const isLetter = /[A-Z]/.test(upper);
193
+ const isDigit = /[0-9]/.test(name);
194
+ const windowsVirtualKeyCode = isLetter || isDigit ? upper.charCodeAt(0) : name.charCodeAt(0);
195
+ const spec = {
196
+ key: name,
197
+ code: isLetter ? `Key${upper}` : isDigit ? `Digit${name}` : '',
198
+ windowsVirtualKeyCode,
199
+ modifiers,
200
+ };
201
+ if (modifiers === 0) spec.text = name;
202
+ return spec;
203
+ }
204
+ throw new BrowserError('BAD_KEY', `Unsupported key "${raw}"`);
205
+ }
206
+
207
+ export class BrowserManager {
208
+ #config;
209
+ #logger;
210
+ #proc = null;
211
+ #cdp = null;
212
+ #sessionId = null;
213
+ #targetId = null;
214
+ #starting = null;
215
+ #running = false;
216
+ #executable = '';
217
+ #url = 'about:blank';
218
+ #title = '';
219
+ #actions = [];
220
+ #frames = [];
221
+ #latestFrame = null;
222
+ #actionSeq = 0;
223
+ #frameSeq = 0;
224
+ #lastCaptureAt = 0;
225
+ #lastError = '';
226
+ #tail = Promise.resolve();
227
+ #disposed = false;
228
+
229
+ constructor({ config = {}, logger } = {}) {
230
+ this.#config = normalizeConfig(config);
231
+ this.#logger = logger;
232
+ }
233
+
234
+ get config() {
235
+ return this.#config;
236
+ }
237
+
238
+ get running() {
239
+ return this.#running;
240
+ }
241
+
242
+ /** Serialize every browser operation: one page, one action at a time. */
243
+ #serial(fn) {
244
+ const run = this.#tail.then(fn, fn);
245
+ this.#tail = run.then(
246
+ () => undefined,
247
+ () => undefined,
248
+ );
249
+ return run;
250
+ }
251
+
252
+ // -------------------------------------------------------------------------
253
+ // Lifecycle
254
+ // -------------------------------------------------------------------------
255
+
256
+ async ensureStarted() {
257
+ if (this.#running && this.#cdp !== null && !this.#cdp.closed) return;
258
+ if (this.#starting !== null) {
259
+ await this.#starting;
260
+ return;
261
+ }
262
+ this.#starting = this.#launch().finally(() => {
263
+ this.#starting = null;
264
+ });
265
+ await this.#starting;
266
+ }
267
+
268
+ async #launch() {
269
+ if (this.#disposed) throw new BrowserError('DISPOSED', 'The plugin is unloading');
270
+ const executable = findBrowserExecutable(this.#config);
271
+ if (executable === '') {
272
+ throw new BrowserError(
273
+ 'BROWSER_NOT_FOUND',
274
+ 'No Chrome, Edge, or Chromium executable was found. Set `executablePath` in the plugin config, or install Chrome.',
275
+ );
276
+ }
277
+ this.#executable = executable;
278
+ const userDataDir = this.#config.userDataDir.trim() === '' ? defaultUserDataDir() : this.#config.userDataDir.trim();
279
+ try {
280
+ mkdirSync(userDataDir, { recursive: true });
281
+ } catch (error) {
282
+ throw new BrowserError('PROFILE_DIR_FAILED', `Could not create the browser profile directory ${userDataDir}: ${error.message}`);
283
+ }
284
+ const args = [
285
+ '--remote-debugging-port=0',
286
+ `--user-data-dir=${userDataDir}`,
287
+ '--no-first-run',
288
+ '--no-default-browser-check',
289
+ '--disable-background-networking',
290
+ '--disable-component-update',
291
+ '--disable-default-apps',
292
+ '--disable-sync',
293
+ '--disable-extensions',
294
+ '--disable-features=Translate,MediaRouter,OptimizationHints',
295
+ '--metrics-recording-only',
296
+ '--mute-audio',
297
+ '--hide-scrollbars',
298
+ `--window-size=${this.#config.viewportWidth},${this.#config.viewportHeight}`,
299
+ 'about:blank',
300
+ ];
301
+ if (this.#config.headless) args.unshift('--headless=new');
302
+ this.#logger?.info?.(`[visual-browser] launching ${executable} (headless=${this.#config.headless})`);
303
+
304
+ const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
305
+ this.#proc = child;
306
+ let stderr = '';
307
+ let settled = false;
308
+ const endpoint = await new Promise((resolve, reject) => {
309
+ const timer = setTimeout(() => {
310
+ finish(new BrowserError('STARTUP_TIMEOUT', `Chrome did not expose a DevTools endpoint within ${this.#config.startupTimeoutMs}ms. ${stderr.slice(-600)}`));
311
+ }, this.#config.startupTimeoutMs);
312
+ const finish = (error, value) => {
313
+ if (settled) return;
314
+ settled = true;
315
+ clearTimeout(timer);
316
+ child.stderr?.removeListener('data', onData);
317
+ child.removeListener('exit', onExit);
318
+ child.removeListener('error', onError);
319
+ if (error) reject(error);
320
+ else resolve(value);
321
+ };
322
+ const onData = (chunk) => {
323
+ stderr += String(chunk);
324
+ if (stderr.length > 20_000) stderr = stderr.slice(-10_000);
325
+ const match = /DevTools listening on (ws:\/\/[^\s]+)/.exec(stderr);
326
+ if (match) finish(null, match[1]);
327
+ };
328
+ const onExit = (code) => {
329
+ finish(new BrowserError('BROWSER_EXITED', `Chrome exited with code ${code} before it was ready. ${stderr.slice(-600)}`));
330
+ };
331
+ const onError = (error) => {
332
+ finish(new BrowserError('SPAWN_FAILED', `Could not start ${executable}: ${error.message}`));
333
+ };
334
+ child.stderr?.on('data', onData);
335
+ child.once('exit', onExit);
336
+ child.once('error', onError);
337
+ }).catch((error) => {
338
+ try {
339
+ child.kill();
340
+ } catch {
341
+ /* already gone */
342
+ }
343
+ this.#proc = null;
344
+ throw error;
345
+ });
346
+
347
+ const cdp = await CdpConnection.open(endpoint);
348
+ this.#cdp = cdp;
349
+ child.once('exit', () => {
350
+ if (this.#proc === child) this.#proc = null;
351
+ this.#running = false;
352
+ this.#sessionId = null;
353
+ this.#targetId = null;
354
+ cdp.close();
355
+ });
356
+ await this.#attach();
357
+ this.#running = true;
358
+ this.#lastError = '';
359
+ this.#record('start', '', `${this.#config.headless ? 'headless' : 'windowed'} Chrome started`);
360
+ await this.#capture({ force: true }).catch(() => null);
361
+ }
362
+
363
+ async #attach() {
364
+ const cdp = this.#cdp;
365
+ if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
366
+ const created = await cdp.send('Target.createTarget', { url: 'about:blank' });
367
+ const targetId = created?.targetId;
368
+ if (typeof targetId !== 'string') throw new BrowserError('ATTACH_FAILED', 'Chrome did not return a page target');
369
+ const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
370
+ const sessionId = attached?.sessionId;
371
+ if (typeof sessionId !== 'string') throw new BrowserError('ATTACH_FAILED', 'Chrome did not attach a flattened session');
372
+ this.#targetId = targetId;
373
+ this.#sessionId = sessionId;
374
+ await cdp.send('Page.enable', {}, sessionId);
375
+ await cdp.send('Runtime.enable', {}, sessionId);
376
+ await cdp.send(
377
+ 'Emulation.setDeviceMetricsOverride',
378
+ {
379
+ width: this.#config.viewportWidth,
380
+ height: this.#config.viewportHeight,
381
+ deviceScaleFactor: 1,
382
+ mobile: false,
383
+ },
384
+ sessionId,
385
+ );
386
+ // A blocking alert() would freeze every later snapshot; accept and log it.
387
+ cdp.on('Page.javascriptDialogOpening', (params, sid) => {
388
+ if (sid !== this.#sessionId) return;
389
+ const message = String(params?.message ?? '');
390
+ void cdp.send('Page.handleJavaScriptDialog', { accept: true }, sid).catch(() => undefined);
391
+ this.#record('dialog', message.slice(0, 160), `dialog accepted: ${message.slice(0, 120)}`);
392
+ });
393
+ cdp.on('Page.frameNavigated', (params, sid) => {
394
+ if (sid !== this.#sessionId) return;
395
+ const frame = params?.frame;
396
+ if (!frame || frame.parentId) return;
397
+ if (typeof frame.url === 'string' && frame.url !== '' && frame.url !== 'about:blank') this.#url = frame.url;
398
+ });
399
+ }
400
+
401
+ /** Shut Chrome down; safe to call when it never started. */
402
+ async close() {
403
+ const proc = this.#proc;
404
+ this.#proc = null;
405
+ this.#running = false;
406
+ this.#sessionId = null;
407
+ this.#targetId = null;
408
+ const cdp = this.#cdp;
409
+ this.#cdp = null;
410
+ cdp?.close();
411
+ if (proc !== null) {
412
+ try {
413
+ if (process.platform === 'win32') {
414
+ spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
415
+ } else {
416
+ proc.kill('SIGTERM');
417
+ }
418
+ } catch {
419
+ /* already gone */
420
+ }
421
+ }
422
+ this.#record('close', '', 'browser closed');
423
+ this.#latestFrame = null;
424
+ this.#frames = [];
425
+ }
426
+
427
+ dispose() {
428
+ this.#disposed = true;
429
+ void this.close().catch(() => undefined);
430
+ }
431
+
432
+ // -------------------------------------------------------------------------
433
+ // Observation
434
+ // -------------------------------------------------------------------------
435
+
436
+ async #evaluate(expression) {
437
+ const cdp = this.#cdp;
438
+ if (cdp === null || this.#sessionId === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
439
+ const result = await cdp.send(
440
+ 'Runtime.evaluate',
441
+ { expression, returnByValue: true, awaitPromise: true, userGesture: false },
442
+ this.#sessionId,
443
+ );
444
+ if (result?.exceptionDetails) {
445
+ const text = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? 'evaluation failed';
446
+ throw new BrowserError('PAGE_SCRIPT_FAILED', String(text).split('\n')[0]);
447
+ }
448
+ return result?.result?.value;
449
+ }
450
+
451
+ /** Full compact snapshot of the current page. */
452
+ async snapshot(options = {}) {
453
+ const value = await this.#evaluate(
454
+ snapshotExpression({
455
+ maxElements: options.maxElements ?? this.#config.maxElements,
456
+ maxTextChars: options.maxTextChars ?? this.#config.maxTextChars,
457
+ maxNameChars: this.#config.maxNameChars,
458
+ }),
459
+ );
460
+ if (value === null || typeof value !== 'object') {
461
+ throw new BrowserError('SNAPSHOT_FAILED', 'The page returned no snapshot data');
462
+ }
463
+ this.#url = String(value.url ?? this.#url);
464
+ this.#title = String(value.title ?? '');
465
+ return value;
466
+ }
467
+
468
+ async facts() {
469
+ const value = await this.#evaluate(pageFactsExpression());
470
+ if (value && typeof value === 'object') {
471
+ this.#url = String(value.url ?? this.#url);
472
+ this.#title = String(value.title ?? this.#title);
473
+ }
474
+ return value ?? null;
475
+ }
476
+
477
+ /** Read text of one element (ref/selector) or of the whole page. */
478
+ async readText(options = {}) {
479
+ const value = await this.#evaluate(
480
+ readTextExpression({
481
+ ref: options.ref,
482
+ selector: options.selector,
483
+ maxChars: options.maxChars ?? 4000,
484
+ }),
485
+ );
486
+ if (value && value.ok === false) {
487
+ throw new BrowserError(
488
+ value.reason === 'stale-ref' ? 'STALE_REF' : 'TARGET_NOT_FOUND',
489
+ value.reason === 'stale-ref'
490
+ ? 'That element number is stale: take a fresh snapshot and use the new numbers.'
491
+ : `No element matched ${options.ref ? `[${options.ref}]` : options.selector}`,
492
+ );
493
+ }
494
+ return value;
495
+ }
496
+
497
+ // -------------------------------------------------------------------------
498
+ // Actions
499
+ // -------------------------------------------------------------------------
500
+
501
+ /** Open a URL, waiting for the first load, and return a fresh snapshot. */
502
+ async open(url) {
503
+ const target = normalizeUrl(url);
504
+ await this.ensureStarted();
505
+ return this.#serial(async () => {
506
+ await this.#navigate(target);
507
+ const snapshot = await this.snapshot();
508
+ this.#record('open', target, snapshot.title || '');
509
+ await this.#capture().catch(() => null);
510
+ return snapshot;
511
+ });
512
+ }
513
+
514
+ async #navigate(url) {
515
+ const cdp = this.#cdp;
516
+ if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
517
+ const nav = await cdp.send('Page.navigate', { url }, this.#sessionId);
518
+ if (nav?.errorText) throw new BrowserError('NAVIGATION_FAILED', `Could not open ${url}: ${nav.errorText}`);
519
+ this.#url = url;
520
+ await this.#waitReady(this.#config.navigationTimeoutMs);
521
+ }
522
+
523
+ async #waitReady(timeoutMs) {
524
+ const deadline = Date.now() + timeoutMs;
525
+ let stable = 0;
526
+ while (Date.now() < deadline) {
527
+ let facts = null;
528
+ try {
529
+ facts = await this.#evaluate(pageFactsExpression());
530
+ } catch {
531
+ facts = null;
532
+ }
533
+ if (facts && facts.readyState === 'complete') {
534
+ stable += 1;
535
+ if (stable >= 2) {
536
+ await sleep(120);
537
+ return facts;
538
+ }
539
+ } else {
540
+ stable = 0;
541
+ }
542
+ await sleep(120);
543
+ }
544
+ return null;
545
+ }
546
+
547
+ /** Wait for a click-triggered navigation or a short quiet window. */
548
+ async #settleAfterAction() {
549
+ const cdp = this.#cdp;
550
+ if (cdp === null) return;
551
+ let navigated = false;
552
+ const start = Date.now();
553
+ const off = cdp.on('Page.frameNavigated', (params, sid) => {
554
+ if (sid !== this.#sessionId) return;
555
+ if (params?.frame && !params.frame.parentId) navigated = true;
556
+ });
557
+ let quietSince = start;
558
+ try {
559
+ while (Date.now() - start < this.#config.actionTimeoutMs) {
560
+ let facts = null;
561
+ try {
562
+ facts = await this.#evaluate(pageFactsExpression());
563
+ } catch {
564
+ facts = null;
565
+ }
566
+ const now = Date.now();
567
+ if (facts && facts.url !== this.#url) {
568
+ this.#url = String(facts.url);
569
+ quietSince = now;
570
+ }
571
+ if (facts && facts.readyState !== 'complete') quietSince = now;
572
+ const idle = navigated ? now - quietSince > 250 : now - start > 500;
573
+ if (idle && (!navigated || (facts && facts.readyState === 'complete'))) break;
574
+ await sleep(100);
575
+ }
576
+ } finally {
577
+ off();
578
+ }
579
+ await sleep(80);
580
+ }
581
+
582
+ async #mouse(type, x, y, extra = {}) {
583
+ const cdp = this.#cdp;
584
+ if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
585
+ await cdp.send('Input.dispatchMouseEvent', { type, x, y, ...extra }, this.#sessionId);
586
+ }
587
+
588
+ async #resolveTarget(target) {
589
+ const resolved = await this.#evaluate(resolveExpression(target));
590
+ if (!resolved || resolved.ok !== true) {
591
+ const reason = resolved?.reason ?? 'not-found';
592
+ const messages = {
593
+ 'stale-ref': 'That element number is stale: take a fresh snapshot and use the new numbers.',
594
+ 'not-found': `No element matched ${target.ref ? `[${target.ref}]` : target.selector}`,
595
+ 'not-visible': `The element ${target.ref ? `[${target.ref}]` : target.selector} is not visible in the viewport; scroll it into view first.`,
596
+ 'bad-selector': `"${target.selector}" is not a valid CSS selector.`,
597
+ };
598
+ throw new BrowserError(
599
+ reason === 'stale-ref' ? 'STALE_REF' : 'TARGET_NOT_FOUND',
600
+ messages[reason] ?? `Could not resolve ${target.ref ? `[${target.ref}]` : target.selector}`,
601
+ resolved ?? undefined,
602
+ );
603
+ }
604
+ return resolved;
605
+ }
606
+
607
+ async #clickResolved(resolved) {
608
+ await this.#mouse('mouseMoved', resolved.x, resolved.y);
609
+ await this.#mouse('mousePressed', resolved.x, resolved.y, { button: 'left', clickCount: 1 });
610
+ await this.#mouse('mouseReleased', resolved.x, resolved.y, { button: 'left', clickCount: 1 });
611
+ }
612
+
613
+ async #typeInto(target, text, mode) {
614
+ const prepared = await this.#evaluate(prepareTypingExpression({ ...target, mode }));
615
+ if (!prepared || prepared.ok !== true) {
616
+ const reason = prepared?.reason ?? 'not-found';
617
+ throw new BrowserError(
618
+ reason === 'stale-ref' ? 'STALE_REF' : 'TARGET_NOT_FOUND',
619
+ reason === 'stale-ref'
620
+ ? 'That element number is stale: take a fresh snapshot and use the new numbers.'
621
+ : reason === 'disabled'
622
+ ? 'The field is disabled.'
623
+ : reason === 'readonly'
624
+ ? 'The field is read-only.'
625
+ : `No typable element matched ${target.ref ? `[${target.ref}]` : target.selector}`,
626
+ );
627
+ }
628
+ const value = String(text ?? '');
629
+ if (value !== '') {
630
+ const cdp = this.#cdp;
631
+ await cdp.send('Input.insertText', { text: value }, this.#sessionId);
632
+ const after = await this.#evaluate(
633
+ `(() => { const el = document.activeElement; if (!el) return ''; if (typeof el.value === 'string') return el.value; return el.innerText || ''; })()`,
634
+ );
635
+ const expected = mode === 'append' ? `${prepared.valueBefore ?? ''}${value}` : value;
636
+ if (typeof after === 'string' && after !== expected && !String(after).includes(value)) {
637
+ // Some editors ignore synthetic text input; write through the native
638
+ // setter and dispatch the events frameworks listen for.
639
+ await this.#evaluate(`(() => {
640
+ const el = document.activeElement;
641
+ if (!el) return false;
642
+ const setter = Object.getOwnPropertyDescriptor(
643
+ el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype,
644
+ 'value',
645
+ );
646
+ if (setter && setter.set) setter.set.call(el, ${JSON.stringify(expected)});
647
+ else el.textContent = ${JSON.stringify(expected)};
648
+ el.dispatchEvent(new Event('input', { bubbles: true }));
649
+ el.dispatchEvent(new Event('change', { bubbles: true }));
650
+ return true;
651
+ })()`);
652
+ }
653
+ }
654
+ return prepared;
655
+ }
656
+
657
+ async #press(combo) {
658
+ const spec = keySpec(combo);
659
+ const cdp = this.#cdp;
660
+ if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
661
+ const base = {
662
+ key: spec.key,
663
+ code: spec.code || undefined,
664
+ windowsVirtualKeyCode: spec.windowsVirtualKeyCode,
665
+ nativeVirtualKeyCode: spec.windowsVirtualKeyCode,
666
+ modifiers: spec.modifiers ?? 0,
667
+ };
668
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, ...(spec.text ? { text: spec.text } : {}) }, this.#sessionId);
669
+ await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }, this.#sessionId);
670
+ return spec;
671
+ }
672
+
673
+ async #scrollBy(direction, amount) {
674
+ const viewportHeight = this.#config.viewportHeight;
675
+ const viewportWidth = this.#config.viewportWidth;
676
+ const step = Math.max(120, Math.round(viewportHeight * 0.85)) * Math.max(1, Number(amount) || 1);
677
+ const deltaY = direction === 'up' ? -step : direction === 'down' ? step : 0;
678
+ const deltaX = direction === 'left' ? -step : direction === 'right' ? step : 0;
679
+ await this.#mouse('mouseWheel', Math.round(viewportWidth / 2), Math.round(viewportHeight / 2), {
680
+ deltaX,
681
+ deltaY,
682
+ pointerType: 'mouse',
683
+ });
684
+ return { deltaX, deltaY };
685
+ }
686
+
687
+ /**
688
+ * One entry point for every page action. Returns `{ summary, snapshot }`.
689
+ * The caller (tool or route) decides how much of the snapshot to show.
690
+ */
691
+ async act(input = {}) {
692
+ const action = String(input.action ?? '').trim();
693
+ if (action === '') throw new BrowserError('BAD_ACTION', 'An action is required');
694
+ if (action === 'close') {
695
+ await this.close();
696
+ return { summary: 'browser closed', snapshot: null };
697
+ }
698
+ await this.ensureStarted();
699
+ return this.#serial(async () => {
700
+ const target = {
701
+ ref: input.ref === undefined || input.ref === null || input.ref === '' ? undefined : Number(input.ref),
702
+ selector: typeof input.selector === 'string' && input.selector.trim() !== '' ? input.selector.trim() : undefined,
703
+ };
704
+ let summary = '';
705
+ switch (action) {
706
+ case 'click': {
707
+ if (target.ref === undefined && target.selector === undefined) {
708
+ throw new BrowserError('BAD_ACTION', 'click needs a ref (from the last snapshot) or a selector');
709
+ }
710
+ const resolved = await this.#resolveTarget(target);
711
+ await this.#clickResolved(resolved);
712
+ summary = `click ${target.ref ? `[${target.ref}]` : target.selector}${resolved.name ? ` “${resolved.name}”` : ''}`;
713
+ break;
714
+ }
715
+ case 'type': {
716
+ if (target.ref === undefined && target.selector === undefined) {
717
+ throw new BrowserError('BAD_ACTION', 'type needs a ref (from the last snapshot) or a selector');
718
+ }
719
+ if (typeof input.text !== 'string') throw new BrowserError('BAD_ACTION', 'type needs text');
720
+ const prepared = await this.#typeInto(target, input.text, input.clear === false ? 'append' : 'replace');
721
+ summary = `type ${target.ref ? `[${target.ref}]` : target.selector} · ${input.text.length} chars`;
722
+ if (input.submit === true) {
723
+ await this.#press('Enter');
724
+ summary += ' + Enter';
725
+ }
726
+ break;
727
+ }
728
+ case 'key': {
729
+ if (typeof input.key !== 'string' || input.key.trim() === '') throw new BrowserError('BAD_ACTION', 'key needs a key name such as Enter');
730
+ const spec = await this.#press(input.key);
731
+ summary = `press ${spec.key}`;
732
+ break;
733
+ }
734
+ case 'hover': {
735
+ const resolved = await this.#resolveTarget(target);
736
+ await this.#mouse('mouseMoved', resolved.x, resolved.y);
737
+ summary = `hover ${target.ref ? `[${target.ref}]` : target.selector}`;
738
+ break;
739
+ }
740
+ case 'scroll': {
741
+ const direction = String(input.direction ?? 'down');
742
+ if (!['up', 'down', 'left', 'right'].includes(direction)) {
743
+ throw new BrowserError('BAD_ACTION', 'scroll direction must be up, down, left, or right');
744
+ }
745
+ const moved = await this.#scrollBy(direction, input.amount);
746
+ summary = `scroll ${direction} ${Math.abs(moved.deltaY || moved.deltaX)}px`;
747
+ break;
748
+ }
749
+ case 'select': {
750
+ const value = input.value ?? input.text;
751
+ if (value === undefined) throw new BrowserError('BAD_ACTION', 'select needs a value');
752
+ const picked = await this.#evaluate(selectOptionExpression({ ...target, value }));
753
+ if (!picked || picked.ok !== true) {
754
+ throw new BrowserError(
755
+ 'TARGET_NOT_FOUND',
756
+ picked?.reason === 'not-a-select'
757
+ ? 'That element is not a native <select>.'
758
+ : `The option "${value}" was not found.`,
759
+ picked ?? undefined,
760
+ );
761
+ }
762
+ summary = `select “${picked.text}”`;
763
+ break;
764
+ }
765
+ case 'back':
766
+ case 'forward': {
767
+ await this.#evaluate(`history.${action}()`);
768
+ await this.#waitReady(this.#config.navigationTimeoutMs);
769
+ summary = action === 'back' ? 'back one page' : 'forward one page';
770
+ break;
771
+ }
772
+ case 'reload': {
773
+ const cdp = this.#cdp;
774
+ await cdp.send('Page.reload', { ignoreCache: false }, this.#sessionId);
775
+ await this.#waitReady(this.#config.navigationTimeoutMs);
776
+ summary = 'reload page';
777
+ break;
778
+ }
779
+ case 'wait': {
780
+ const ms = Math.min(30_000, Math.max(0, Number(input.ms) || 800));
781
+ await sleep(ms);
782
+ summary = `wait ${ms}ms`;
783
+ break;
784
+ }
785
+ case 'snapshot': {
786
+ summary = 'read page';
787
+ break;
788
+ }
789
+ default:
790
+ throw new BrowserError('BAD_ACTION', `Unknown action "${action}"`);
791
+ }
792
+ await this.#settleAfterAction();
793
+ const snapshot = await this.snapshot();
794
+ this.#record(action, target.ref !== undefined ? `[${target.ref}]` : (target.selector ?? ''), summary.slice(0, 160));
795
+ await this.#capture().catch(() => null);
796
+ return { summary, snapshot };
797
+ });
798
+ }
799
+
800
+ /** A click from the panel: normalized 0..1 coordinates over the frame. */
801
+ async clickAt(input = {}) {
802
+ if (!this.#config.allowUserControl) throw new BrowserError('USER_CONTROL_DISABLED', 'User clicks are disabled in this plugin config');
803
+ const x = Math.min(1, Math.max(0, Number(input.x)));
804
+ const y = Math.min(1, Math.max(0, Number(input.y)));
805
+ if (!Number.isFinite(x) || !Number.isFinite(y)) throw new BrowserError('BAD_ACTION', 'Click coordinates are required');
806
+ await this.ensureStarted();
807
+ return this.#serial(async () => {
808
+ const px = Math.round(x * this.#config.viewportWidth);
809
+ const py = Math.round(y * this.#config.viewportHeight);
810
+ await this.#clickResolved({ x: px, y: py, name: '' });
811
+ await this.#settleAfterAction();
812
+ const snapshot = await this.snapshot();
813
+ this.#record('user-click', `${Math.round(x * 100)}%,${Math.round(y * 100)}%`, `you clicked ${Math.round(x * 100)}%, ${Math.round(y * 100)}%`);
814
+ await this.#capture().catch(() => null);
815
+ return { summary: 'clicked in the panel', snapshot };
816
+ });
817
+ }
818
+
819
+ /** A wheel event from the panel. */
820
+ async wheelAt(input = {}) {
821
+ if (!this.#config.allowUserControl) throw new BrowserError('USER_CONTROL_DISABLED', 'User input is disabled in this plugin config');
822
+ const deltaY = Math.max(-4000, Math.min(4000, Number(input.deltaY) || 0));
823
+ const deltaX = Math.max(-4000, Math.min(4000, Number(input.deltaX) || 0));
824
+ if (deltaY === 0 && deltaX === 0) return { summary: 'no wheel movement' };
825
+ await this.ensureStarted();
826
+ return this.#serial(async () => {
827
+ await this.#mouse('mouseWheel', Math.round(this.#config.viewportWidth / 2), Math.round(this.#config.viewportHeight / 2), {
828
+ deltaX,
829
+ deltaY,
830
+ pointerType: 'mouse',
831
+ });
832
+ await sleep(180);
833
+ this.#record('user-scroll', `${deltaY || deltaX}px`, `you scrolled ${Math.abs(deltaY || deltaX)}px`);
834
+ await this.#capture().catch(() => null);
835
+ return { summary: 'scrolled in the panel' };
836
+ });
837
+ }
838
+
839
+ /** A key press from the panel (Enter, Tab, Escape, arrows). */
840
+ async keyFromUser(key) {
841
+ if (!this.#config.allowUserControl) throw new BrowserError('USER_CONTROL_DISABLED', 'User input is disabled in this plugin config');
842
+ await this.ensureStarted();
843
+ return this.#serial(async () => {
844
+ const spec = await this.#press(key);
845
+ await this.#settleAfterAction();
846
+ this.#record('user-key', spec.key, `you pressed ${spec.key}`);
847
+ await this.#capture().catch(() => null);
848
+ return { summary: `pressed ${spec.key}` };
849
+ });
850
+ }
851
+
852
+ /** Capture one screenshot to a file; returns the absolute path. */
853
+ async screenshot(options = {}) {
854
+ await this.ensureStarted();
855
+ return this.#serial(async () => {
856
+ const format = options.format === 'jpeg' ? 'jpeg' : 'png';
857
+ const result = await this.#cdp.send(
858
+ 'Page.captureScreenshot',
859
+ {
860
+ format,
861
+ fromSurface: true,
862
+ captureBeyondViewport: false,
863
+ ...(format === 'jpeg' ? { quality: 88 } : {}),
864
+ },
865
+ this.#sessionId,
866
+ );
867
+ const bytes = Buffer.from(String(result?.data ?? ''), 'base64');
868
+ if (bytes.length === 0) throw new BrowserError('SCREENSHOT_FAILED', 'Chrome returned an empty screenshot');
869
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
870
+ const requested = typeof options.path === 'string' && options.path.trim() !== '' ? options.path.trim() : '';
871
+ const filePath = requested !== '' ? path.resolve(requested) : path.join(tmpdir(), 'dsh-visual-browser', `page-${timestamp}.${format}`);
872
+ mkdirSync(path.dirname(filePath), { recursive: true });
873
+ await writeFile(filePath, bytes);
874
+ this.#record('screenshot', path.basename(filePath), `saved ${format} screenshot`);
875
+ return { path: filePath, bytes: bytes.length, format };
876
+ });
877
+ }
878
+
879
+ // -------------------------------------------------------------------------
880
+ // Frames and timeline
881
+ // -------------------------------------------------------------------------
882
+
883
+ async #capture(options = {}) {
884
+ if (!this.#running || this.#cdp === null || this.#sessionId === null) return null;
885
+ const now = Date.now();
886
+ if (!options.force && this.#latestFrame !== null && now - this.#lastCaptureAt < 350) return this.#latestFrame;
887
+ const result = await this.#cdp.send(
888
+ 'Page.captureScreenshot',
889
+ { format: 'jpeg', quality: this.#config.frameQuality, fromSurface: true, captureBeyondViewport: false },
890
+ this.#sessionId,
891
+ );
892
+ const jpeg = Buffer.from(String(result?.data ?? ''), 'base64');
893
+ if (jpeg.length === 0) return null;
894
+ const frame = { seq: ++this.#frameSeq, ts: now, jpeg };
895
+ this.#latestFrame = frame;
896
+ this.#lastCaptureAt = now;
897
+ this.#frames.push(frame);
898
+ while (this.#frames.length > this.#config.timelineFrames) this.#frames.shift();
899
+ return frame;
900
+ }
901
+
902
+ /** Capture a fresh frame for the panel's refresh button / live mode. */
903
+ async refreshFrame() {
904
+ await this.ensureStarted();
905
+ return this.#serial(async () => {
906
+ await this.#capture({ force: true });
907
+ return this.state();
908
+ });
909
+ }
910
+
911
+ #record(kind, target, detail, ok = true) {
912
+ const entry = {
913
+ seq: ++this.#actionSeq,
914
+ kind,
915
+ target: String(target ?? ''),
916
+ detail: String(detail ?? ''),
917
+ ok: ok !== false,
918
+ ts: Date.now(),
919
+ frameSeq: this.#latestFrame ? this.#latestFrame.seq : 0,
920
+ };
921
+ this.#actions.unshift(entry);
922
+ if (this.#actions.length > 80) this.#actions.length = 80;
923
+ return entry;
924
+ }
925
+
926
+ frameFor(seq) {
927
+ const wanted = Number(seq);
928
+ if (!Number.isFinite(wanted) || wanted <= 0) return this.#latestFrame;
929
+ if (this.#latestFrame !== null && this.#latestFrame.seq === wanted) return this.#latestFrame;
930
+ return this.#frames.find((frame) => frame.seq === wanted) ?? null;
931
+ }
932
+
933
+ /** JSON payload the panel polls; frames and buffers stay on the host. */
934
+ state() {
935
+ return {
936
+ running: this.#running,
937
+ starting: this.#starting !== null,
938
+ executable: this.#executable,
939
+ headless: this.#config.headless,
940
+ url: this.#url,
941
+ title: this.#title,
942
+ viewport: { width: this.#config.viewportWidth, height: this.#config.viewportHeight },
943
+ frameSeq: this.#latestFrame ? this.#latestFrame.seq : 0,
944
+ actionSeq: this.#actionSeq,
945
+ frameSeqs: this.#frames.map((frame) => frame.seq),
946
+ actions: this.#actions.map((action) => ({
947
+ seq: action.seq,
948
+ kind: action.kind,
949
+ target: action.target,
950
+ detail: action.detail,
951
+ ok: action.ok,
952
+ ts: action.ts,
953
+ frameSeq: action.frameSeq,
954
+ })),
955
+ allowUserControl: this.#config.allowUserControl,
956
+ lastError: this.#lastError,
957
+ };
958
+ }
959
+
960
+ noteError(error) {
961
+ this.#lastError = error instanceof Error ? error.message : String(error);
962
+ }
963
+ }