@restregular/dsh-visual-browser 0.1.0 → 0.2.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 +57 -21
- package/client.js +1077 -437
- package/lib/browser.js +1255 -963
- package/lib/cdp.js +208 -192
- package/lib/routes.js +101 -47
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +6 -6
- package/test/format.test.mjs +62 -0
- package/test/package.test.mjs +68 -0
- package/test/routes.test.mjs +248 -0
- package/test/scripts.test.mjs +214 -0
package/lib/browser.js
CHANGED
|
@@ -1,963 +1,1255 @@
|
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
'
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
await
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
} catch {
|
|
419
|
-
/* already
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.#
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
} catch {
|
|
531
|
-
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
await
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
async
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
reason === 'stale-ref'
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
);
|
|
603
|
-
}
|
|
604
|
-
return
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
);
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
async #
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
async
|
|
692
|
-
const
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
await this
|
|
699
|
-
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
if (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
'
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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 { readFile, 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
|
+
streamQuality: 60,
|
|
46
|
+
allowUserControl: true,
|
|
47
|
+
reuseExisting: 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
|
+
streamQuality: clamp(source.streamQuality ?? source.frameQuality, 20, 95, DEFAULT_CONFIG.streamQuality),
|
|
74
|
+
allowUserControl: source.allowUserControl === undefined ? DEFAULT_CONFIG.allowUserControl : source.allowUserControl !== false,
|
|
75
|
+
reuseExisting: source.reuseExisting === undefined ? DEFAULT_CONFIG.reuseExisting : source.reuseExisting !== 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
|
+
#owned = false;
|
|
217
|
+
#executable = '';
|
|
218
|
+
#url = 'about:blank';
|
|
219
|
+
#title = '';
|
|
220
|
+
#actions = [];
|
|
221
|
+
#actionSeq = 0;
|
|
222
|
+
#startedAt = 0;
|
|
223
|
+
#lastRecordAt = new Map();
|
|
224
|
+
#wheelTotal = 0;
|
|
225
|
+
#wheelAt = 0;
|
|
226
|
+
#typedBuffer = '';
|
|
227
|
+
#typedAt = 0;
|
|
228
|
+
#lastError = '';
|
|
229
|
+
#tail = Promise.resolve();
|
|
230
|
+
#disposed = false;
|
|
231
|
+
/** Live picture subscribers; the screencast runs only while this is non-empty. */
|
|
232
|
+
#frameListeners = new Set();
|
|
233
|
+
#screencastSession = null;
|
|
234
|
+
#screencastPending = null;
|
|
235
|
+
|
|
236
|
+
constructor({ config = {}, logger } = {}) {
|
|
237
|
+
this.#config = normalizeConfig(config);
|
|
238
|
+
this.#logger = logger;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get config() {
|
|
242
|
+
return this.#config;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get running() {
|
|
246
|
+
return this.#running;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Serialize every browser operation: one page, one action at a time. */
|
|
250
|
+
#serial(fn) {
|
|
251
|
+
const run = this.#tail.then(fn, fn);
|
|
252
|
+
this.#tail = run.then(
|
|
253
|
+
() => undefined,
|
|
254
|
+
() => undefined,
|
|
255
|
+
);
|
|
256
|
+
return run;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
// Lifecycle
|
|
261
|
+
// -------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
async ensureStarted() {
|
|
264
|
+
if (this.#running && this.#cdp !== null && !this.#cdp.closed) return;
|
|
265
|
+
if (this.#starting !== null) {
|
|
266
|
+
await this.#starting;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.#starting = this.#launch().finally(() => {
|
|
270
|
+
this.#starting = null;
|
|
271
|
+
});
|
|
272
|
+
await this.#starting;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async #launch() {
|
|
276
|
+
if (this.#disposed) throw new BrowserError('DISPOSED', 'The plugin is unloading');
|
|
277
|
+
const executable = findBrowserExecutable(this.#config);
|
|
278
|
+
if (executable === '') {
|
|
279
|
+
throw new BrowserError(
|
|
280
|
+
'BROWSER_NOT_FOUND',
|
|
281
|
+
'No Chrome, Edge, or Chromium executable was found. Set `executablePath` in the plugin config, or install Chrome.',
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
this.#executable = executable;
|
|
285
|
+
const userDataDir = this.#config.userDataDir.trim() === '' ? defaultUserDataDir() : this.#config.userDataDir.trim();
|
|
286
|
+
try {
|
|
287
|
+
mkdirSync(userDataDir, { recursive: true });
|
|
288
|
+
} catch (error) {
|
|
289
|
+
throw new BrowserError('PROFILE_DIR_FAILED', `Could not create the browser profile directory ${userDataDir}: ${error.message}`);
|
|
290
|
+
}
|
|
291
|
+
if (this.#config.reuseExisting) {
|
|
292
|
+
const reused = await this.#reuseProfile(userDataDir).catch((error) => {
|
|
293
|
+
this.#logger?.info?.(`[visual-browser] reuse probe failed: ${error?.message ?? error}`);
|
|
294
|
+
return false;
|
|
295
|
+
});
|
|
296
|
+
if (reused) {
|
|
297
|
+
this.#owned = false;
|
|
298
|
+
await this.#attach();
|
|
299
|
+
this.#running = true;
|
|
300
|
+
this.#lastError = '';
|
|
301
|
+
this.#startedAt = Date.now();
|
|
302
|
+
if (this.#frameListeners.size > 0) void this.#restartScreencast();
|
|
303
|
+
this.#record('start', userDataDir, 'attached to a running Chrome');
|
|
304
|
+
this.#logger?.info?.('[visual-browser] attached to the Chrome that already owns this profile');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const args = [
|
|
309
|
+
'--remote-debugging-port=0',
|
|
310
|
+
`--user-data-dir=${userDataDir}`,
|
|
311
|
+
'--no-first-run',
|
|
312
|
+
'--no-default-browser-check',
|
|
313
|
+
'--disable-background-networking',
|
|
314
|
+
'--disable-component-update',
|
|
315
|
+
'--disable-default-apps',
|
|
316
|
+
'--disable-sync',
|
|
317
|
+
'--disable-extensions',
|
|
318
|
+
'--disable-features=Translate,MediaRouter,OptimizationHints',
|
|
319
|
+
'--metrics-recording-only',
|
|
320
|
+
'--mute-audio',
|
|
321
|
+
'--hide-scrollbars',
|
|
322
|
+
`--window-size=${this.#config.viewportWidth},${this.#config.viewportHeight}`,
|
|
323
|
+
'about:blank',
|
|
324
|
+
];
|
|
325
|
+
if (this.#config.headless) args.unshift('--headless=new');
|
|
326
|
+
this.#logger?.info?.(`[visual-browser] launching ${executable} (headless=${this.#config.headless})`);
|
|
327
|
+
|
|
328
|
+
const child = spawn(executable, args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
|
|
329
|
+
this.#proc = child;
|
|
330
|
+
let stderr = '';
|
|
331
|
+
let settled = false;
|
|
332
|
+
const endpoint = await new Promise((resolve, reject) => {
|
|
333
|
+
const timer = setTimeout(() => {
|
|
334
|
+
finish(new BrowserError('STARTUP_TIMEOUT', `Chrome did not expose a DevTools endpoint within ${this.#config.startupTimeoutMs}ms. ${stderr.slice(-600)}`));
|
|
335
|
+
}, this.#config.startupTimeoutMs);
|
|
336
|
+
const finish = (error, value) => {
|
|
337
|
+
if (settled) return;
|
|
338
|
+
settled = true;
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
child.stderr?.removeListener('data', onData);
|
|
341
|
+
child.removeListener('exit', onExit);
|
|
342
|
+
child.removeListener('error', onError);
|
|
343
|
+
if (error) reject(error);
|
|
344
|
+
else resolve(value);
|
|
345
|
+
};
|
|
346
|
+
const onData = (chunk) => {
|
|
347
|
+
stderr += String(chunk);
|
|
348
|
+
if (stderr.length > 20_000) stderr = stderr.slice(-10_000);
|
|
349
|
+
const match = /DevTools listening on (ws:\/\/[^\s]+)/.exec(stderr);
|
|
350
|
+
if (match) finish(null, match[1]);
|
|
351
|
+
};
|
|
352
|
+
const onExit = (code) => {
|
|
353
|
+
const hint =
|
|
354
|
+
stderr.trim() === '' && code === 0
|
|
355
|
+
? 'Chrome handed the launch to a browser that already owns this profile, but no DevTools endpoint answered. Close the leftover browser (the panel Stop button, or Task Manager) or set a different `userDataDir`.'
|
|
356
|
+
: stderr.slice(-600);
|
|
357
|
+
finish(new BrowserError('BROWSER_EXITED', `Chrome exited with code ${code} before it was ready. ${hint}`));
|
|
358
|
+
};
|
|
359
|
+
const onError = (error) => {
|
|
360
|
+
finish(new BrowserError('SPAWN_FAILED', `Could not start ${executable}: ${error.message}`));
|
|
361
|
+
};
|
|
362
|
+
child.stderr?.on('data', onData);
|
|
363
|
+
child.once('exit', onExit);
|
|
364
|
+
child.once('error', onError);
|
|
365
|
+
}).catch((error) => {
|
|
366
|
+
try {
|
|
367
|
+
child.kill();
|
|
368
|
+
} catch {
|
|
369
|
+
/* already gone */
|
|
370
|
+
}
|
|
371
|
+
this.#proc = null;
|
|
372
|
+
throw error;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const cdp = await CdpConnection.open(endpoint);
|
|
376
|
+
this.#owned = true;
|
|
377
|
+
this.#cdp = cdp;
|
|
378
|
+
child.once('exit', () => {
|
|
379
|
+
if (this.#proc === child) this.#proc = null;
|
|
380
|
+
this.#running = false;
|
|
381
|
+
this.#sessionId = null;
|
|
382
|
+
this.#targetId = null;
|
|
383
|
+
cdp.close();
|
|
384
|
+
});
|
|
385
|
+
await this.#attach();
|
|
386
|
+
this.#running = true;
|
|
387
|
+
this.#lastError = '';
|
|
388
|
+
this.#startedAt = Date.now();
|
|
389
|
+
if (this.#frameListeners.size > 0) void this.#restartScreencast();
|
|
390
|
+
this.#record('start', '', `${this.#config.headless ? 'headless' : 'windowed'} Chrome started`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Chrome writes `<userDataDir>/DevToolsActivePort` when it starts with remote
|
|
395
|
+
* debugging. A host restart, or a crashed host whose Chrome survived, can
|
|
396
|
+
* therefore adopt the running browser instead of failing on the profile lock.
|
|
397
|
+
*/
|
|
398
|
+
async #reuseProfile(userDataDir) {
|
|
399
|
+
const portFile = path.join(userDataDir, 'DevToolsActivePort');
|
|
400
|
+
let text = '';
|
|
401
|
+
try {
|
|
402
|
+
text = await readFile(portFile, 'utf8');
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim());
|
|
407
|
+
const port = Number(lines[0]);
|
|
408
|
+
const wsPath = lines[1] ?? '';
|
|
409
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return false;
|
|
410
|
+
if (!wsPath.startsWith('/devtools/browser/')) return false;
|
|
411
|
+
let cdp = null;
|
|
412
|
+
try {
|
|
413
|
+
cdp = await CdpConnection.open(`ws://127.0.0.1:${port}${wsPath}`, { timeoutMs: 3000 });
|
|
414
|
+
await cdp.send('Browser.getVersion');
|
|
415
|
+
} catch {
|
|
416
|
+
try {
|
|
417
|
+
cdp?.close();
|
|
418
|
+
} catch {
|
|
419
|
+
/* already closed */
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
this.#cdp = cdp;
|
|
424
|
+
cdp.onClose(() => {
|
|
425
|
+
if (this.#cdp === cdp) {
|
|
426
|
+
this.#running = false;
|
|
427
|
+
this.#sessionId = null;
|
|
428
|
+
this.#targetId = null;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async #attach() {
|
|
435
|
+
const cdp = this.#cdp;
|
|
436
|
+
if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
|
|
437
|
+
const created = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
438
|
+
const targetId = created?.targetId;
|
|
439
|
+
if (typeof targetId !== 'string') throw new BrowserError('ATTACH_FAILED', 'Chrome did not return a page target');
|
|
440
|
+
const attached = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
441
|
+
const sessionId = attached?.sessionId;
|
|
442
|
+
if (typeof sessionId !== 'string') throw new BrowserError('ATTACH_FAILED', 'Chrome did not attach a flattened session');
|
|
443
|
+
this.#targetId = targetId;
|
|
444
|
+
this.#sessionId = sessionId;
|
|
445
|
+
this.#screencastSession = null;
|
|
446
|
+
this.#screencastPending = null;
|
|
447
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
448
|
+
await cdp.send('Runtime.enable', {}, sessionId);
|
|
449
|
+
await cdp.send(
|
|
450
|
+
'Emulation.setDeviceMetricsOverride',
|
|
451
|
+
{
|
|
452
|
+
width: this.#config.viewportWidth,
|
|
453
|
+
height: this.#config.viewportHeight,
|
|
454
|
+
deviceScaleFactor: 1,
|
|
455
|
+
mobile: false,
|
|
456
|
+
},
|
|
457
|
+
sessionId,
|
|
458
|
+
);
|
|
459
|
+
// A blocking alert() would freeze every later snapshot; accept and log it.
|
|
460
|
+
cdp.on('Page.javascriptDialogOpening', (params, sid) => {
|
|
461
|
+
if (sid !== this.#sessionId) return;
|
|
462
|
+
const message = String(params?.message ?? '');
|
|
463
|
+
void cdp.send('Page.handleJavaScriptDialog', { accept: true }, sid).catch(() => undefined);
|
|
464
|
+
this.#record('dialog', message.slice(0, 160), `dialog accepted: ${message.slice(0, 120)}`);
|
|
465
|
+
});
|
|
466
|
+
cdp.on('Page.frameNavigated', (params, sid) => {
|
|
467
|
+
if (sid !== this.#sessionId) return;
|
|
468
|
+
const frame = params?.frame;
|
|
469
|
+
if (!frame || frame.parentId) return;
|
|
470
|
+
if (typeof frame.url === 'string' && frame.url !== '' && frame.url !== 'about:blank') this.#url = frame.url;
|
|
471
|
+
});
|
|
472
|
+
// Chrome pushes one JPEG per painted change while a screencast is running;
|
|
473
|
+
// acking immediately keeps it flowing, and every subscriber gets the very
|
|
474
|
+
// same buffer, so N panels never cost N captures.
|
|
475
|
+
cdp.on('Page.screencastFrame', (params, sid) => {
|
|
476
|
+
if (sid !== this.#sessionId) return;
|
|
477
|
+
if (typeof params?.sessionId === 'number') {
|
|
478
|
+
void cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId }, sid).catch(() => undefined);
|
|
479
|
+
}
|
|
480
|
+
const data = params?.data;
|
|
481
|
+
if (typeof data !== 'string' || data === '') return;
|
|
482
|
+
if (this.#frameListeners.size === 0) return;
|
|
483
|
+
const jpeg = Buffer.from(data, 'base64');
|
|
484
|
+
if (jpeg.length === 0) return;
|
|
485
|
+
for (const listener of [...this.#frameListeners]) {
|
|
486
|
+
try {
|
|
487
|
+
listener(jpeg);
|
|
488
|
+
} catch {
|
|
489
|
+
/* one broken subscriber must not stop the stream */
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Shut Chrome down (or just detach when this host did not spawn it). */
|
|
496
|
+
async close() {
|
|
497
|
+
const proc = this.#proc;
|
|
498
|
+
const owned = this.#owned;
|
|
499
|
+
const cdp = this.#cdp;
|
|
500
|
+
const targetId = this.#targetId;
|
|
501
|
+
this.#proc = null;
|
|
502
|
+
this.#owned = false;
|
|
503
|
+
this.#running = false;
|
|
504
|
+
this.#startedAt = 0;
|
|
505
|
+
this.#sessionId = null;
|
|
506
|
+
this.#targetId = null;
|
|
507
|
+
this.#screencastSession = null;
|
|
508
|
+
this.#screencastPending = null;
|
|
509
|
+
this.#cdp = null;
|
|
510
|
+
if (owned) {
|
|
511
|
+
cdp?.close();
|
|
512
|
+
} else if (cdp !== null && targetId !== null) {
|
|
513
|
+
// Only our own tab is ours to close: another host may still use the browser.
|
|
514
|
+
try {
|
|
515
|
+
await Promise.race([cdp.send('Target.closeTarget', { targetId }), sleep(1000)]);
|
|
516
|
+
} catch {
|
|
517
|
+
/* the browser may already be gone */
|
|
518
|
+
}
|
|
519
|
+
cdp.close();
|
|
520
|
+
} else {
|
|
521
|
+
cdp?.close();
|
|
522
|
+
}
|
|
523
|
+
if (owned && proc !== null) {
|
|
524
|
+
try {
|
|
525
|
+
if (process.platform === 'win32') {
|
|
526
|
+
spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
527
|
+
} else {
|
|
528
|
+
proc.kill('SIGTERM');
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
/* already gone */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
this.#record('close', '', 'browser closed');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
dispose() {
|
|
538
|
+
this.#disposed = true;
|
|
539
|
+
void this.close().catch(() => undefined);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// -------------------------------------------------------------------------
|
|
543
|
+
// Observation
|
|
544
|
+
// -------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
async #evaluate(expression) {
|
|
547
|
+
const cdp = this.#cdp;
|
|
548
|
+
if (cdp === null || this.#sessionId === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
|
|
549
|
+
const result = await cdp.send(
|
|
550
|
+
'Runtime.evaluate',
|
|
551
|
+
{ expression, returnByValue: true, awaitPromise: true, userGesture: false },
|
|
552
|
+
this.#sessionId,
|
|
553
|
+
);
|
|
554
|
+
if (result?.exceptionDetails) {
|
|
555
|
+
const text = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? 'evaluation failed';
|
|
556
|
+
throw new BrowserError('PAGE_SCRIPT_FAILED', String(text).split('\n')[0]);
|
|
557
|
+
}
|
|
558
|
+
return result?.result?.value;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Full compact snapshot of the current page. */
|
|
562
|
+
async snapshot(options = {}) {
|
|
563
|
+
const value = await this.#evaluate(
|
|
564
|
+
snapshotExpression({
|
|
565
|
+
maxElements: options.maxElements ?? this.#config.maxElements,
|
|
566
|
+
maxTextChars: options.maxTextChars ?? this.#config.maxTextChars,
|
|
567
|
+
maxNameChars: this.#config.maxNameChars,
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
if (value === null || typeof value !== 'object') {
|
|
571
|
+
throw new BrowserError('SNAPSHOT_FAILED', 'The page returned no snapshot data');
|
|
572
|
+
}
|
|
573
|
+
this.#url = String(value.url ?? this.#url);
|
|
574
|
+
this.#title = String(value.title ?? '');
|
|
575
|
+
return value;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async facts() {
|
|
579
|
+
const value = await this.#evaluate(pageFactsExpression());
|
|
580
|
+
if (value && typeof value === 'object') {
|
|
581
|
+
this.#url = String(value.url ?? this.#url);
|
|
582
|
+
this.#title = String(value.title ?? this.#title);
|
|
583
|
+
}
|
|
584
|
+
return value ?? null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Read text of one element (ref/selector) or of the whole page. */
|
|
588
|
+
async readText(options = {}) {
|
|
589
|
+
const value = await this.#evaluate(
|
|
590
|
+
readTextExpression({
|
|
591
|
+
ref: options.ref,
|
|
592
|
+
selector: options.selector,
|
|
593
|
+
maxChars: options.maxChars ?? 4000,
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
if (value && value.ok === false) {
|
|
597
|
+
throw new BrowserError(
|
|
598
|
+
value.reason === 'stale-ref' ? 'STALE_REF' : 'TARGET_NOT_FOUND',
|
|
599
|
+
value.reason === 'stale-ref'
|
|
600
|
+
? 'That element number is stale: take a fresh snapshot and use the new numbers.'
|
|
601
|
+
: `No element matched ${options.ref ? `[${options.ref}]` : options.selector}`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return value;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// -------------------------------------------------------------------------
|
|
608
|
+
// Actions
|
|
609
|
+
// -------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
/** Open a URL, waiting for the first load, and return a fresh snapshot. */
|
|
612
|
+
async open(url) {
|
|
613
|
+
const target = normalizeUrl(url);
|
|
614
|
+
await this.ensureStarted();
|
|
615
|
+
return this.#serial(async () => {
|
|
616
|
+
await this.#navigate(target);
|
|
617
|
+
const snapshot = await this.snapshot();
|
|
618
|
+
this.#record('open', target, snapshot.title || '');
|
|
619
|
+
return snapshot;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async #navigate(url) {
|
|
624
|
+
const cdp = this.#cdp;
|
|
625
|
+
if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
|
|
626
|
+
const nav = await cdp.send('Page.navigate', { url }, this.#sessionId);
|
|
627
|
+
if (nav?.errorText) throw new BrowserError('NAVIGATION_FAILED', `Could not open ${url}: ${nav.errorText}`);
|
|
628
|
+
this.#url = url;
|
|
629
|
+
await this.#waitReady(this.#config.navigationTimeoutMs);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async #waitReady(timeoutMs) {
|
|
633
|
+
const deadline = Date.now() + timeoutMs;
|
|
634
|
+
let stable = 0;
|
|
635
|
+
while (Date.now() < deadline) {
|
|
636
|
+
let facts = null;
|
|
637
|
+
try {
|
|
638
|
+
facts = await this.#evaluate(pageFactsExpression());
|
|
639
|
+
} catch {
|
|
640
|
+
facts = null;
|
|
641
|
+
}
|
|
642
|
+
if (facts && facts.readyState === 'complete') {
|
|
643
|
+
stable += 1;
|
|
644
|
+
if (stable >= 2) {
|
|
645
|
+
await sleep(120);
|
|
646
|
+
return facts;
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
stable = 0;
|
|
650
|
+
}
|
|
651
|
+
await sleep(120);
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Wait for a click-triggered navigation or a short quiet window. */
|
|
657
|
+
async #settleAfterAction() {
|
|
658
|
+
const cdp = this.#cdp;
|
|
659
|
+
if (cdp === null) return;
|
|
660
|
+
let navigated = false;
|
|
661
|
+
const start = Date.now();
|
|
662
|
+
const off = cdp.on('Page.frameNavigated', (params, sid) => {
|
|
663
|
+
if (sid !== this.#sessionId) return;
|
|
664
|
+
if (params?.frame && !params.frame.parentId) navigated = true;
|
|
665
|
+
});
|
|
666
|
+
let quietSince = start;
|
|
667
|
+
try {
|
|
668
|
+
while (Date.now() - start < this.#config.actionTimeoutMs) {
|
|
669
|
+
let facts = null;
|
|
670
|
+
try {
|
|
671
|
+
facts = await this.#evaluate(pageFactsExpression());
|
|
672
|
+
} catch {
|
|
673
|
+
facts = null;
|
|
674
|
+
}
|
|
675
|
+
const now = Date.now();
|
|
676
|
+
if (facts && facts.url !== this.#url) {
|
|
677
|
+
this.#url = String(facts.url);
|
|
678
|
+
quietSince = now;
|
|
679
|
+
}
|
|
680
|
+
if (facts && facts.readyState !== 'complete') quietSince = now;
|
|
681
|
+
const idle = navigated ? now - quietSince > 250 : now - start > 500;
|
|
682
|
+
if (idle && (!navigated || (facts && facts.readyState === 'complete'))) break;
|
|
683
|
+
await sleep(100);
|
|
684
|
+
}
|
|
685
|
+
} finally {
|
|
686
|
+
off();
|
|
687
|
+
}
|
|
688
|
+
await sleep(80);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async #mouse(type, x, y, extra = {}) {
|
|
692
|
+
const cdp = this.#cdp;
|
|
693
|
+
if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
|
|
694
|
+
await cdp.send('Input.dispatchMouseEvent', { type, x, y, ...extra }, this.#sessionId);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async #resolveTarget(target) {
|
|
698
|
+
const resolved = await this.#evaluate(resolveExpression(target));
|
|
699
|
+
if (!resolved || resolved.ok !== true) {
|
|
700
|
+
const reason = resolved?.reason ?? 'not-found';
|
|
701
|
+
const messages = {
|
|
702
|
+
'stale-ref': 'That element number is stale: take a fresh snapshot and use the new numbers.',
|
|
703
|
+
'not-found': `No element matched ${target.ref ? `[${target.ref}]` : target.selector}`,
|
|
704
|
+
'not-visible': `The element ${target.ref ? `[${target.ref}]` : target.selector} is not visible in the viewport; scroll it into view first.`,
|
|
705
|
+
'bad-selector': `"${target.selector}" is not a valid CSS selector.`,
|
|
706
|
+
};
|
|
707
|
+
throw new BrowserError(
|
|
708
|
+
reason === 'stale-ref' ? 'STALE_REF' : 'TARGET_NOT_FOUND',
|
|
709
|
+
messages[reason] ?? `Could not resolve ${target.ref ? `[${target.ref}]` : target.selector}`,
|
|
710
|
+
resolved ?? undefined,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return resolved;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async #clickResolved(resolved) {
|
|
717
|
+
await this.#mouse('mouseMoved', resolved.x, resolved.y);
|
|
718
|
+
await this.#mouse('mousePressed', resolved.x, resolved.y, { button: 'left', clickCount: 1 });
|
|
719
|
+
await this.#mouse('mouseReleased', resolved.x, resolved.y, { button: 'left', clickCount: 1 });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async #typeInto(target, text, mode) {
|
|
723
|
+
const prepared = await this.#evaluate(prepareTypingExpression({ ...target, mode }));
|
|
724
|
+
if (!prepared || prepared.ok !== true) {
|
|
725
|
+
const reason = prepared?.reason ?? 'not-found';
|
|
726
|
+
throw new BrowserError(
|
|
727
|
+
reason === 'stale-ref' ? 'STALE_REF' : 'TARGET_NOT_FOUND',
|
|
728
|
+
reason === 'stale-ref'
|
|
729
|
+
? 'That element number is stale: take a fresh snapshot and use the new numbers.'
|
|
730
|
+
: reason === 'disabled'
|
|
731
|
+
? 'The field is disabled.'
|
|
732
|
+
: reason === 'readonly'
|
|
733
|
+
? 'The field is read-only.'
|
|
734
|
+
: `No typable element matched ${target.ref ? `[${target.ref}]` : target.selector}`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const value = String(text ?? '');
|
|
738
|
+
if (value !== '') {
|
|
739
|
+
const cdp = this.#cdp;
|
|
740
|
+
await cdp.send('Input.insertText', { text: value }, this.#sessionId);
|
|
741
|
+
const after = await this.#evaluate(
|
|
742
|
+
`(() => { const el = document.activeElement; if (!el) return ''; if (typeof el.value === 'string') return el.value; return el.innerText || ''; })()`,
|
|
743
|
+
);
|
|
744
|
+
const expected = mode === 'append' ? `${prepared.valueBefore ?? ''}${value}` : value;
|
|
745
|
+
if (typeof after === 'string' && after !== expected && !String(after).includes(value)) {
|
|
746
|
+
// Some editors ignore synthetic text input; write through the native
|
|
747
|
+
// setter and dispatch the events frameworks listen for.
|
|
748
|
+
await this.#evaluate(`(() => {
|
|
749
|
+
const el = document.activeElement;
|
|
750
|
+
if (!el) return false;
|
|
751
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
752
|
+
el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype,
|
|
753
|
+
'value',
|
|
754
|
+
);
|
|
755
|
+
if (setter && setter.set) setter.set.call(el, ${JSON.stringify(expected)});
|
|
756
|
+
else el.textContent = ${JSON.stringify(expected)};
|
|
757
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
758
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
759
|
+
return true;
|
|
760
|
+
})()`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return prepared;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async #press(combo) {
|
|
767
|
+
const spec = keySpec(combo);
|
|
768
|
+
const cdp = this.#cdp;
|
|
769
|
+
if (cdp === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
|
|
770
|
+
const base = {
|
|
771
|
+
key: spec.key,
|
|
772
|
+
code: spec.code || undefined,
|
|
773
|
+
windowsVirtualKeyCode: spec.windowsVirtualKeyCode,
|
|
774
|
+
nativeVirtualKeyCode: spec.windowsVirtualKeyCode,
|
|
775
|
+
modifiers: spec.modifiers ?? 0,
|
|
776
|
+
};
|
|
777
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, ...(spec.text ? { text: spec.text } : {}) }, this.#sessionId);
|
|
778
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }, this.#sessionId);
|
|
779
|
+
return spec;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async #scrollBy(direction, amount) {
|
|
783
|
+
const viewportHeight = this.#config.viewportHeight;
|
|
784
|
+
const viewportWidth = this.#config.viewportWidth;
|
|
785
|
+
const step = Math.max(120, Math.round(viewportHeight * 0.85)) * Math.max(1, Number(amount) || 1);
|
|
786
|
+
const deltaY = direction === 'up' ? -step : direction === 'down' ? step : 0;
|
|
787
|
+
const deltaX = direction === 'left' ? -step : direction === 'right' ? step : 0;
|
|
788
|
+
await this.#mouse('mouseWheel', Math.round(viewportWidth / 2), Math.round(viewportHeight / 2), {
|
|
789
|
+
deltaX,
|
|
790
|
+
deltaY,
|
|
791
|
+
pointerType: 'mouse',
|
|
792
|
+
});
|
|
793
|
+
return { deltaX, deltaY };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* One entry point for every page action. Returns `{ summary, snapshot }`.
|
|
798
|
+
* The caller (tool or route) decides how much of the snapshot to show.
|
|
799
|
+
*/
|
|
800
|
+
async act(input = {}) {
|
|
801
|
+
const action = String(input.action ?? '').trim();
|
|
802
|
+
if (action === '') throw new BrowserError('BAD_ACTION', 'An action is required');
|
|
803
|
+
if (action === 'close') {
|
|
804
|
+
await this.close();
|
|
805
|
+
return { summary: 'browser closed', snapshot: null };
|
|
806
|
+
}
|
|
807
|
+
await this.ensureStarted();
|
|
808
|
+
return this.#serial(async () => {
|
|
809
|
+
const target = {
|
|
810
|
+
ref: input.ref === undefined || input.ref === null || input.ref === '' ? undefined : Number(input.ref),
|
|
811
|
+
selector: typeof input.selector === 'string' && input.selector.trim() !== '' ? input.selector.trim() : undefined,
|
|
812
|
+
};
|
|
813
|
+
let summary = '';
|
|
814
|
+
switch (action) {
|
|
815
|
+
case 'click': {
|
|
816
|
+
if (target.ref === undefined && target.selector === undefined) {
|
|
817
|
+
throw new BrowserError('BAD_ACTION', 'click needs a ref (from the last snapshot) or a selector');
|
|
818
|
+
}
|
|
819
|
+
const resolved = await this.#resolveTarget(target);
|
|
820
|
+
await this.#clickResolved(resolved);
|
|
821
|
+
summary = `click ${target.ref ? `[${target.ref}]` : target.selector}${resolved.name ? ` “${resolved.name}”` : ''}`;
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
case 'type': {
|
|
825
|
+
if (target.ref === undefined && target.selector === undefined) {
|
|
826
|
+
throw new BrowserError('BAD_ACTION', 'type needs a ref (from the last snapshot) or a selector');
|
|
827
|
+
}
|
|
828
|
+
if (typeof input.text !== 'string') throw new BrowserError('BAD_ACTION', 'type needs text');
|
|
829
|
+
const prepared = await this.#typeInto(target, input.text, input.clear === false ? 'append' : 'replace');
|
|
830
|
+
summary = `type ${target.ref ? `[${target.ref}]` : target.selector} · ${input.text.length} chars`;
|
|
831
|
+
if (input.submit === true) {
|
|
832
|
+
await this.#press('Enter');
|
|
833
|
+
summary += ' + Enter';
|
|
834
|
+
}
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
case 'key': {
|
|
838
|
+
if (typeof input.key !== 'string' || input.key.trim() === '') throw new BrowserError('BAD_ACTION', 'key needs a key name such as Enter');
|
|
839
|
+
const spec = await this.#press(input.key);
|
|
840
|
+
summary = `press ${spec.key}`;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
case 'hover': {
|
|
844
|
+
const resolved = await this.#resolveTarget(target);
|
|
845
|
+
await this.#mouse('mouseMoved', resolved.x, resolved.y);
|
|
846
|
+
summary = `hover ${target.ref ? `[${target.ref}]` : target.selector}`;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
case 'scroll': {
|
|
850
|
+
const direction = String(input.direction ?? 'down');
|
|
851
|
+
if (!['up', 'down', 'left', 'right'].includes(direction)) {
|
|
852
|
+
throw new BrowserError('BAD_ACTION', 'scroll direction must be up, down, left, or right');
|
|
853
|
+
}
|
|
854
|
+
const moved = await this.#scrollBy(direction, input.amount);
|
|
855
|
+
summary = `scroll ${direction} ${Math.abs(moved.deltaY || moved.deltaX)}px`;
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case 'select': {
|
|
859
|
+
const value = input.value ?? input.text;
|
|
860
|
+
if (value === undefined) throw new BrowserError('BAD_ACTION', 'select needs a value');
|
|
861
|
+
const picked = await this.#evaluate(selectOptionExpression({ ...target, value }));
|
|
862
|
+
if (!picked || picked.ok !== true) {
|
|
863
|
+
throw new BrowserError(
|
|
864
|
+
'TARGET_NOT_FOUND',
|
|
865
|
+
picked?.reason === 'not-a-select'
|
|
866
|
+
? 'That element is not a native <select>.'
|
|
867
|
+
: `The option "${value}" was not found.`,
|
|
868
|
+
picked ?? undefined,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
summary = `select “${picked.text}”`;
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
case 'back':
|
|
875
|
+
case 'forward': {
|
|
876
|
+
await this.#evaluate(`history.${action}()`);
|
|
877
|
+
await this.#waitReady(this.#config.navigationTimeoutMs);
|
|
878
|
+
summary = action === 'back' ? 'back one page' : 'forward one page';
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
case 'reload': {
|
|
882
|
+
const cdp = this.#cdp;
|
|
883
|
+
await cdp.send('Page.reload', { ignoreCache: false }, this.#sessionId);
|
|
884
|
+
await this.#waitReady(this.#config.navigationTimeoutMs);
|
|
885
|
+
summary = 'reload page';
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case 'wait': {
|
|
889
|
+
const ms = Math.min(30_000, Math.max(0, Number(input.ms) || 800));
|
|
890
|
+
await sleep(ms);
|
|
891
|
+
summary = `wait ${ms}ms`;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
case 'snapshot': {
|
|
895
|
+
summary = 'read page';
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
default:
|
|
899
|
+
throw new BrowserError('BAD_ACTION', `Unknown action "${action}"`);
|
|
900
|
+
}
|
|
901
|
+
await this.#settleAfterAction();
|
|
902
|
+
const snapshot = await this.snapshot();
|
|
903
|
+
this.#record(action, target.ref !== undefined ? `[${target.ref}]` : (target.selector ?? ''), summary.slice(0, 160));
|
|
904
|
+
return { summary, snapshot };
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Capture one screenshot to a file; returns the absolute path. */
|
|
909
|
+
async screenshot(options = {}) {
|
|
910
|
+
await this.ensureStarted();
|
|
911
|
+
return this.#serial(async () => {
|
|
912
|
+
const format = options.format === 'jpeg' ? 'jpeg' : 'png';
|
|
913
|
+
const result = await this.#cdp.send(
|
|
914
|
+
'Page.captureScreenshot',
|
|
915
|
+
{
|
|
916
|
+
format,
|
|
917
|
+
fromSurface: true,
|
|
918
|
+
captureBeyondViewport: false,
|
|
919
|
+
...(format === 'jpeg' ? { quality: 88 } : {}),
|
|
920
|
+
},
|
|
921
|
+
this.#sessionId,
|
|
922
|
+
);
|
|
923
|
+
const bytes = Buffer.from(String(result?.data ?? ''), 'base64');
|
|
924
|
+
if (bytes.length === 0) throw new BrowserError('SCREENSHOT_FAILED', 'Chrome returned an empty screenshot');
|
|
925
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
926
|
+
const requested = typeof options.path === 'string' && options.path.trim() !== '' ? options.path.trim() : '';
|
|
927
|
+
const filePath = requested !== '' ? path.resolve(requested) : path.join(tmpdir(), 'dsh-visual-browser', `page-${timestamp}.${format}`);
|
|
928
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
929
|
+
await writeFile(filePath, bytes);
|
|
930
|
+
this.#record('screenshot', path.basename(filePath), `saved ${format} screenshot`);
|
|
931
|
+
return { path: filePath, bytes: bytes.length, format };
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// -------------------------------------------------------------------------
|
|
936
|
+
// Live picture (CDP screencast -> MJPEG)
|
|
937
|
+
// -------------------------------------------------------------------------
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Start Chrome's own screencast: it pushes one JPEG per painted frame instead
|
|
941
|
+
* of the panel asking for screenshots, so the picture follows clicks, drags
|
|
942
|
+
* and scrolling at paint speed without a request per frame.
|
|
943
|
+
*/
|
|
944
|
+
async #ensureScreencast() {
|
|
945
|
+
const cdp = this.#cdp;
|
|
946
|
+
const sessionId = this.#sessionId;
|
|
947
|
+
if (!this.#running || cdp === null || sessionId === null) return false;
|
|
948
|
+
if (this.#screencastSession === sessionId) return true;
|
|
949
|
+
if (this.#screencastPending !== null) {
|
|
950
|
+
await this.#screencastPending.catch(() => false);
|
|
951
|
+
return this.#screencastSession === this.#sessionId;
|
|
952
|
+
}
|
|
953
|
+
this.#screencastPending = (async () => {
|
|
954
|
+
await cdp.send(
|
|
955
|
+
'Page.startScreencast',
|
|
956
|
+
{
|
|
957
|
+
format: 'jpeg',
|
|
958
|
+
quality: this.#config.streamQuality,
|
|
959
|
+
maxWidth: this.#config.viewportWidth,
|
|
960
|
+
maxHeight: this.#config.viewportHeight,
|
|
961
|
+
everyNthFrame: 1,
|
|
962
|
+
},
|
|
963
|
+
sessionId,
|
|
964
|
+
);
|
|
965
|
+
this.#screencastSession = sessionId;
|
|
966
|
+
return true;
|
|
967
|
+
})();
|
|
968
|
+
try {
|
|
969
|
+
return await this.#screencastPending;
|
|
970
|
+
} catch (error) {
|
|
971
|
+
this.#logger?.warn?.(`[visual-browser] screencast unavailable: ${error?.message ?? error}`);
|
|
972
|
+
return false;
|
|
973
|
+
} finally {
|
|
974
|
+
this.#screencastPending = null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async #stopScreencast() {
|
|
979
|
+
const cdp = this.#cdp;
|
|
980
|
+
const sessionId = this.#screencastSession;
|
|
981
|
+
this.#screencastSession = null;
|
|
982
|
+
if (cdp === null || sessionId === null || cdp.closed) return;
|
|
983
|
+
try {
|
|
984
|
+
await cdp.send('Page.stopScreencast', {}, sessionId);
|
|
985
|
+
} catch {
|
|
986
|
+
/* the target may already be gone */
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Subscribe to live JPEG frames. The first subscriber restarts the
|
|
992
|
+
* screencast, because Chrome only pushes a frame when the page paints: a
|
|
993
|
+
* fresh start is what makes a still page appear immediately.
|
|
994
|
+
* @param {(jpeg: Buffer) => void} listener
|
|
995
|
+
* @returns {() => void} unsubscribe
|
|
996
|
+
*/
|
|
997
|
+
subscribeFrames(listener) {
|
|
998
|
+
if (typeof listener !== 'function') return () => {};
|
|
999
|
+
const first = this.#frameListeners.size === 0;
|
|
1000
|
+
this.#frameListeners.add(listener);
|
|
1001
|
+
if (first) void this.#restartScreencast();
|
|
1002
|
+
else void this.#ensureScreencast();
|
|
1003
|
+
return () => {
|
|
1004
|
+
if (!this.#frameListeners.delete(listener)) return;
|
|
1005
|
+
if (this.#frameListeners.size === 0) void this.#stopScreencast();
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async #restartScreencast() {
|
|
1010
|
+
await this.#stopScreencast();
|
|
1011
|
+
if (this.#frameListeners.size === 0) return;
|
|
1012
|
+
await this.#ensureScreencast();
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Ask for a picture even when nothing is painted (used when a panel opens). */
|
|
1016
|
+
refreshLiveView() {
|
|
1017
|
+
if (!this.#running) return;
|
|
1018
|
+
if (this.#frameListeners.size > 0) void this.#restartScreencast();
|
|
1019
|
+
else void this.#ensureScreencast();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// -------------------------------------------------------------------------
|
|
1023
|
+
// User input from the panel
|
|
1024
|
+
// -------------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Dispatch a batch of panel input events straight to Chrome. Nothing here
|
|
1028
|
+
* waits for a snapshot or a capture: input latency is one round trip.
|
|
1029
|
+
* @param {Array<object>} events
|
|
1030
|
+
*/
|
|
1031
|
+
async dispatchInput(events) {
|
|
1032
|
+
if (!Array.isArray(events) || events.length === 0) return { applied: 0 };
|
|
1033
|
+
if (!this.#config.allowUserControl) {
|
|
1034
|
+
throw new BrowserError('USER_CONTROL_DISABLED', 'User input is disabled in this plugin config');
|
|
1035
|
+
}
|
|
1036
|
+
await this.ensureStarted();
|
|
1037
|
+
const cdp = this.#cdp;
|
|
1038
|
+
const sessionId = this.#sessionId;
|
|
1039
|
+
if (cdp === null || sessionId === null) throw new BrowserError('NOT_RUNNING', 'The browser is not running');
|
|
1040
|
+
const width = this.#config.viewportWidth;
|
|
1041
|
+
const height = this.#config.viewportHeight;
|
|
1042
|
+
const pending = [];
|
|
1043
|
+
let applied = 0;
|
|
1044
|
+
for (const event of events.slice(0, 160)) {
|
|
1045
|
+
if (event === null || typeof event !== 'object') continue;
|
|
1046
|
+
try {
|
|
1047
|
+
const commands = this.#inputCommands(event, width, height);
|
|
1048
|
+
for (const command of commands) {
|
|
1049
|
+
pending.push(cdp.send(command.method, command.params, sessionId));
|
|
1050
|
+
applied += 1;
|
|
1051
|
+
}
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
this.noteError(error);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// The socket preserves order, so awaiting is only about surfacing failures.
|
|
1057
|
+
Promise.allSettled(pending).then((settled) => {
|
|
1058
|
+
for (const result of settled) {
|
|
1059
|
+
if (result.status === 'rejected') this.noteError(result.reason);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
return { applied };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Translate one panel event into CDP commands and its timeline record. */
|
|
1066
|
+
#inputCommands(event, width, height) {
|
|
1067
|
+
const type = String(event.t ?? '');
|
|
1068
|
+
const modifiers = Number.isFinite(Number(event.modifiers)) ? Number(event.modifiers) : 0;
|
|
1069
|
+
switch (type) {
|
|
1070
|
+
case 'pointer': {
|
|
1071
|
+
const x = Math.round(clamp01(event.x) * width);
|
|
1072
|
+
const y = Math.round(clamp01(event.y) * height);
|
|
1073
|
+
const phase = String(event.type ?? 'move');
|
|
1074
|
+
const buttons = Number.isFinite(Number(event.buttons)) ? Number(event.buttons) : phase === 'down' ? 1 : 0;
|
|
1075
|
+
const button = phase === 'move' ? 'none' : mouseButton(event.button);
|
|
1076
|
+
if (phase === 'up') {
|
|
1077
|
+
const drag = event.drag === true;
|
|
1078
|
+
this.#recordUser(
|
|
1079
|
+
drag ? 'user-drag' : 'user-click',
|
|
1080
|
+
`${Math.round(clamp01(event.x) * 100)}%,${Math.round(clamp01(event.y) * 100)}%`,
|
|
1081
|
+
drag ? `you dragged to ${Math.round(x)}, ${Math.round(y)}` : `you clicked ${Math.round(x)}, ${Math.round(y)}`,
|
|
1082
|
+
0,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
return [
|
|
1086
|
+
{
|
|
1087
|
+
method: 'Input.dispatchMouseEvent',
|
|
1088
|
+
params: {
|
|
1089
|
+
type: phase === 'down' ? 'mousePressed' : phase === 'up' ? 'mouseReleased' : 'mouseMoved',
|
|
1090
|
+
x,
|
|
1091
|
+
y,
|
|
1092
|
+
button,
|
|
1093
|
+
buttons,
|
|
1094
|
+
clickCount: Math.max(1, Math.min(3, Number(event.clicks) || 1)),
|
|
1095
|
+
modifiers,
|
|
1096
|
+
pointerType: 'mouse',
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
];
|
|
1100
|
+
}
|
|
1101
|
+
case 'wheel': {
|
|
1102
|
+
const x = Math.round(clamp01(event.x) * width);
|
|
1103
|
+
const y = Math.round(clamp01(event.y) * height);
|
|
1104
|
+
const deltaX = clampNumber(event.dx, 4000);
|
|
1105
|
+
const deltaY = clampNumber(event.dy, 4000);
|
|
1106
|
+
if (deltaX === 0 && deltaY === 0) return [];
|
|
1107
|
+
const now = Date.now();
|
|
1108
|
+
if (now - this.#wheelAt > 900) this.#wheelTotal = 0;
|
|
1109
|
+
this.#wheelAt = now;
|
|
1110
|
+
this.#wheelTotal += Math.max(Math.abs(deltaX), Math.abs(deltaY));
|
|
1111
|
+
this.#recordUser(
|
|
1112
|
+
'user-scroll',
|
|
1113
|
+
`${Math.round(deltaX)},${Math.round(deltaY)}`,
|
|
1114
|
+
`you scrolled ${Math.round(this.#wheelTotal)}px`,
|
|
1115
|
+
700,
|
|
1116
|
+
);
|
|
1117
|
+
return [
|
|
1118
|
+
{
|
|
1119
|
+
method: 'Input.dispatchMouseEvent',
|
|
1120
|
+
params: { type: 'mouseWheel', x, y, deltaX, deltaY, modifiers, pointerType: 'mouse' },
|
|
1121
|
+
},
|
|
1122
|
+
];
|
|
1123
|
+
}
|
|
1124
|
+
case 'key': {
|
|
1125
|
+
const phase = String(event.type ?? 'down');
|
|
1126
|
+
const key = String(event.key ?? '');
|
|
1127
|
+
if (key === '') return [];
|
|
1128
|
+
const keyCode = Number.isFinite(Number(event.keyCode)) ? Number(event.keyCode) : 0;
|
|
1129
|
+
const params = {
|
|
1130
|
+
key,
|
|
1131
|
+
code: typeof event.code === 'string' ? event.code : undefined,
|
|
1132
|
+
windowsVirtualKeyCode: keyCode || undefined,
|
|
1133
|
+
nativeVirtualKeyCode: keyCode || undefined,
|
|
1134
|
+
modifiers,
|
|
1135
|
+
};
|
|
1136
|
+
const text = typeof event.text === 'string' ? event.text : '';
|
|
1137
|
+
if (phase === 'up') return [{ method: 'Input.dispatchKeyEvent', params: { type: 'keyUp', ...params } }];
|
|
1138
|
+
if (event.repeat === true) params.autoRepeat = true;
|
|
1139
|
+
if (text === '') {
|
|
1140
|
+
this.#recordUser('user-key', key, `you pressed ${key}`, 0);
|
|
1141
|
+
} else {
|
|
1142
|
+
const now = Date.now();
|
|
1143
|
+
if (now - this.#typedAt > 1400) this.#typedBuffer = '';
|
|
1144
|
+
this.#typedAt = now;
|
|
1145
|
+
this.#typedBuffer = (this.#typedBuffer + text).slice(-40);
|
|
1146
|
+
this.#recordUser('user-type', text, `you typed "${this.#typedBuffer}"`, 600);
|
|
1147
|
+
}
|
|
1148
|
+
return [
|
|
1149
|
+
{
|
|
1150
|
+
method: 'Input.dispatchKeyEvent',
|
|
1151
|
+
params: text === '' ? { type: 'rawKeyDown', ...params } : { type: 'keyDown', ...params, text },
|
|
1152
|
+
},
|
|
1153
|
+
];
|
|
1154
|
+
}
|
|
1155
|
+
case 'text': {
|
|
1156
|
+
const text = typeof event.text === 'string' ? event.text : '';
|
|
1157
|
+
if (text === '') return [];
|
|
1158
|
+
const now = Date.now();
|
|
1159
|
+
if (now - this.#typedAt > 1400) this.#typedBuffer = '';
|
|
1160
|
+
this.#typedAt = now;
|
|
1161
|
+
this.#typedBuffer = (this.#typedBuffer + text).slice(-40);
|
|
1162
|
+
this.#recordUser('user-type', text.slice(0, 40), `you typed "${this.#typedBuffer}"`, 600);
|
|
1163
|
+
return [{ method: 'Input.insertText', params: { text: text.slice(0, 4000) } }];
|
|
1164
|
+
}
|
|
1165
|
+
default:
|
|
1166
|
+
return [];
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// -------------------------------------------------------------------------
|
|
1171
|
+
// Timeline
|
|
1172
|
+
// -------------------------------------------------------------------------
|
|
1173
|
+
|
|
1174
|
+
#record(kind, target, detail, ok = true) {
|
|
1175
|
+
const entry = {
|
|
1176
|
+
seq: ++this.#actionSeq,
|
|
1177
|
+
kind,
|
|
1178
|
+
target: String(target ?? ''),
|
|
1179
|
+
detail: String(detail ?? ''),
|
|
1180
|
+
ok: ok !== false,
|
|
1181
|
+
ts: Date.now(),
|
|
1182
|
+
};
|
|
1183
|
+
this.#actions.unshift(entry);
|
|
1184
|
+
if (this.#actions.length > 80) this.#actions.length = 80;
|
|
1185
|
+
return entry;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Record a user gesture, folding rapid repeats (typing, scrolling) into the
|
|
1190
|
+
* newest row instead of flooding the log with a row per event.
|
|
1191
|
+
*/
|
|
1192
|
+
#recordUser(kind, target, detail, minIntervalMs) {
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
const last = this.#lastRecordAt.get(kind) ?? 0;
|
|
1195
|
+
if (minIntervalMs > 0 && now - last < minIntervalMs) {
|
|
1196
|
+
const newest = this.#actions[0];
|
|
1197
|
+
if (newest !== undefined && newest.kind === kind) {
|
|
1198
|
+
newest.ts = now;
|
|
1199
|
+
newest.target = String(target ?? newest.target);
|
|
1200
|
+
newest.detail = String(detail ?? newest.detail);
|
|
1201
|
+
return newest;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
this.#lastRecordAt.set(kind, now);
|
|
1205
|
+
return this.#record(kind, target, detail);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** JSON payload the panel polls; pictures travel on their own stream. */
|
|
1209
|
+
state() {
|
|
1210
|
+
return {
|
|
1211
|
+
running: this.#running,
|
|
1212
|
+
starting: this.#starting !== null,
|
|
1213
|
+
executable: this.#executable,
|
|
1214
|
+
headless: this.#config.headless,
|
|
1215
|
+
managed: this.#owned,
|
|
1216
|
+
url: this.#url,
|
|
1217
|
+
title: this.#title,
|
|
1218
|
+
viewport: { width: this.#config.viewportWidth, height: this.#config.viewportHeight },
|
|
1219
|
+
startedAt: this.#startedAt,
|
|
1220
|
+
actionSeq: this.#actionSeq,
|
|
1221
|
+
actions: this.#actions.map((action) => ({
|
|
1222
|
+
seq: action.seq,
|
|
1223
|
+
kind: action.kind,
|
|
1224
|
+
target: action.target,
|
|
1225
|
+
detail: action.detail,
|
|
1226
|
+
ok: action.ok,
|
|
1227
|
+
ts: action.ts,
|
|
1228
|
+
})),
|
|
1229
|
+
allowUserControl: this.#config.allowUserControl,
|
|
1230
|
+
lastError: this.#lastError,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
noteError(error) {
|
|
1235
|
+
this.#lastError = error instanceof Error ? error.message : String(error);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function clamp01(value) {
|
|
1240
|
+
const number = Number(value);
|
|
1241
|
+
if (!Number.isFinite(number)) return 0;
|
|
1242
|
+
return Math.min(1, Math.max(0, number));
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function clampNumber(value, limit) {
|
|
1246
|
+
const number = Number(value);
|
|
1247
|
+
if (!Number.isFinite(number)) return 0;
|
|
1248
|
+
return Math.max(-limit, Math.min(limit, number));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function mouseButton(value) {
|
|
1252
|
+
if (value === 2 || value === 'right') return 'right';
|
|
1253
|
+
if (value === 1 || value === 'middle') return 'middle';
|
|
1254
|
+
return 'left';
|
|
1255
|
+
}
|