@silbercue/chrome 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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/build/cache/a11y-tree.d.ts +252 -0
  4. package/build/cache/a11y-tree.js +1956 -0
  5. package/build/cache/index.d.ts +8 -0
  6. package/build/cache/index.js +4 -0
  7. package/build/cache/selector-cache.d.ts +47 -0
  8. package/build/cache/selector-cache.js +119 -0
  9. package/build/cache/session-defaults.d.ts +27 -0
  10. package/build/cache/session-defaults.js +130 -0
  11. package/build/cache/tab-state-cache.d.ts +39 -0
  12. package/build/cache/tab-state-cache.js +171 -0
  13. package/build/cdp/cdp-client.d.ts +25 -0
  14. package/build/cdp/cdp-client.js +146 -0
  15. package/build/cdp/chrome-launcher.d.ts +85 -0
  16. package/build/cdp/chrome-launcher.js +502 -0
  17. package/build/cdp/console-collector.d.ts +53 -0
  18. package/build/cdp/console-collector.js +147 -0
  19. package/build/cdp/debug.d.ts +1 -0
  20. package/build/cdp/debug.js +6 -0
  21. package/build/cdp/dialog-handler.d.ts +54 -0
  22. package/build/cdp/dialog-handler.js +129 -0
  23. package/build/cdp/dom-watcher.d.ts +45 -0
  24. package/build/cdp/dom-watcher.js +195 -0
  25. package/build/cdp/emulation.d.ts +12 -0
  26. package/build/cdp/emulation.js +17 -0
  27. package/build/cdp/index.d.ts +11 -0
  28. package/build/cdp/index.js +6 -0
  29. package/build/cdp/network-collector.d.ts +77 -0
  30. package/build/cdp/network-collector.js +257 -0
  31. package/build/cdp/protocol.d.ts +20 -0
  32. package/build/cdp/protocol.js +1 -0
  33. package/build/cdp/session-manager.d.ts +62 -0
  34. package/build/cdp/session-manager.js +205 -0
  35. package/build/cdp/settle.d.ts +16 -0
  36. package/build/cdp/settle.js +71 -0
  37. package/build/cli/license-commands.d.ts +19 -0
  38. package/build/cli/license-commands.js +199 -0
  39. package/build/cli/top-level-commands.d.ts +49 -0
  40. package/build/cli/top-level-commands.js +222 -0
  41. package/build/hooks/index.d.ts +2 -0
  42. package/build/hooks/index.js +1 -0
  43. package/build/hooks/pro-hooks.d.ts +126 -0
  44. package/build/hooks/pro-hooks.js +17 -0
  45. package/build/index.d.ts +4 -0
  46. package/build/index.js +86 -0
  47. package/build/license/free-tier-config.d.ts +14 -0
  48. package/build/license/free-tier-config.js +18 -0
  49. package/build/license/index.d.ts +4 -0
  50. package/build/license/index.js +2 -0
  51. package/build/license/license-status.d.ts +15 -0
  52. package/build/license/license-status.js +9 -0
  53. package/build/overlay/session-overlay.d.ts +22 -0
  54. package/build/overlay/session-overlay.js +372 -0
  55. package/build/plan/index.d.ts +7 -0
  56. package/build/plan/index.js +4 -0
  57. package/build/plan/plan-conditions.d.ts +12 -0
  58. package/build/plan/plan-conditions.js +242 -0
  59. package/build/plan/plan-executor.d.ts +49 -0
  60. package/build/plan/plan-executor.js +259 -0
  61. package/build/plan/plan-state-store.d.ts +24 -0
  62. package/build/plan/plan-state-store.js +43 -0
  63. package/build/plan/plan-variables.d.ts +16 -0
  64. package/build/plan/plan-variables.js +71 -0
  65. package/build/registry.d.ts +124 -0
  66. package/build/registry.js +884 -0
  67. package/build/server.d.ts +1 -0
  68. package/build/server.js +245 -0
  69. package/build/tools/click.d.ts +34 -0
  70. package/build/tools/click.js +293 -0
  71. package/build/tools/configure-session.d.ts +15 -0
  72. package/build/tools/configure-session.js +45 -0
  73. package/build/tools/console-logs.d.ts +18 -0
  74. package/build/tools/console-logs.js +44 -0
  75. package/build/tools/dom-snapshot.d.ts +13 -0
  76. package/build/tools/dom-snapshot.js +259 -0
  77. package/build/tools/element-utils.d.ts +23 -0
  78. package/build/tools/element-utils.js +133 -0
  79. package/build/tools/error-utils.d.ts +8 -0
  80. package/build/tools/error-utils.js +27 -0
  81. package/build/tools/evaluate.d.ts +34 -0
  82. package/build/tools/evaluate.js +217 -0
  83. package/build/tools/file-upload.d.ts +20 -0
  84. package/build/tools/file-upload.js +174 -0
  85. package/build/tools/fill-form.d.ts +39 -0
  86. package/build/tools/fill-form.js +256 -0
  87. package/build/tools/handle-dialog.d.ts +15 -0
  88. package/build/tools/handle-dialog.js +48 -0
  89. package/build/tools/index.d.ts +35 -0
  90. package/build/tools/index.js +18 -0
  91. package/build/tools/navigate.d.ts +18 -0
  92. package/build/tools/navigate.js +111 -0
  93. package/build/tools/network-monitor.d.ts +18 -0
  94. package/build/tools/network-monitor.js +66 -0
  95. package/build/tools/observe.d.ts +44 -0
  96. package/build/tools/observe.js +339 -0
  97. package/build/tools/press-key.d.ts +33 -0
  98. package/build/tools/press-key.js +155 -0
  99. package/build/tools/read-page.d.ts +22 -0
  100. package/build/tools/read-page.js +100 -0
  101. package/build/tools/run-plan.d.ts +205 -0
  102. package/build/tools/run-plan.js +215 -0
  103. package/build/tools/screenshot.d.ts +16 -0
  104. package/build/tools/screenshot.js +283 -0
  105. package/build/tools/scroll.d.ts +28 -0
  106. package/build/tools/scroll.js +143 -0
  107. package/build/tools/switch-tab.d.ts +26 -0
  108. package/build/tools/switch-tab.js +355 -0
  109. package/build/tools/tab-status.d.ts +7 -0
  110. package/build/tools/tab-status.js +50 -0
  111. package/build/tools/type.d.ts +31 -0
  112. package/build/tools/type.js +247 -0
  113. package/build/tools/virtual-desk.d.ts +7 -0
  114. package/build/tools/virtual-desk.js +108 -0
  115. package/build/tools/visual-constants.d.ts +3 -0
  116. package/build/tools/visual-constants.js +10 -0
  117. package/build/tools/wait-for.d.ts +26 -0
  118. package/build/tools/wait-for.js +323 -0
  119. package/build/transport/index.d.ts +3 -0
  120. package/build/transport/index.js +2 -0
  121. package/build/transport/pipe-transport.d.ts +18 -0
  122. package/build/transport/pipe-transport.js +63 -0
  123. package/build/transport/transport.d.ts +8 -0
  124. package/build/transport/transport.js +1 -0
  125. package/build/transport/websocket-transport.d.ts +22 -0
  126. package/build/transport/websocket-transport.js +200 -0
  127. package/build/types.d.ts +21 -0
  128. package/build/types.js +1 -0
  129. package/package.json +62 -0
@@ -0,0 +1,502 @@
1
+ import { spawn, execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { rm, mkdir } from "node:fs/promises";
4
+ import { request as httpRequest } from "node:http";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { randomBytes } from "node:crypto";
8
+ import { CdpClient } from "./cdp-client.js";
9
+ import { PipeTransport } from "../transport/pipe-transport.js";
10
+ import { WebSocketTransport } from "../transport/websocket-transport.js";
11
+ import { debug } from "./debug.js";
12
+ // ── AutoLaunch Resolution (Story 10.2) ────────────────────────────────
13
+ /**
14
+ * Resolve the autoLaunch setting from environment variables.
15
+ * Pure function — no side effects, fully testable.
16
+ *
17
+ * - SILBERCUE_CHROME_AUTO_LAUNCH=true → always auto-launch
18
+ * - SILBERCUE_CHROME_AUTO_LAUNCH=false → never auto-launch
19
+ * - unset → default: auto-launch (zero-config UX for new users)
20
+ *
21
+ * The `_headless` parameter is kept for backwards-compat with call-sites,
22
+ * but no longer influences the default — auto-launch is the standard path.
23
+ */
24
+ export function resolveAutoLaunch(env, _headless) {
25
+ const val = env.SILBERCUE_CHROME_AUTO_LAUNCH;
26
+ if (val === "true" || val === "1")
27
+ return true;
28
+ if (val === "false" || val === "0")
29
+ return false;
30
+ if (val === undefined) {
31
+ // Default: always auto-launch — user gets zero-config UX
32
+ return true;
33
+ }
34
+ // Invalid env value (e.g. "foo", "bar") → safe default: no auto-launch
35
+ return false;
36
+ }
37
+ // ── Chrome Path Detection (Task 1) ────────────────────────────────────
38
+ const CHROME_PATHS = {
39
+ darwin: [
40
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
41
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
42
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
43
+ ],
44
+ linux: [
45
+ "google-chrome",
46
+ "google-chrome-stable",
47
+ "chromium-browser",
48
+ "chromium",
49
+ ],
50
+ win32: [
51
+ `${process.env.ProgramFiles}\\Google\\Chrome\\Application\\chrome.exe`,
52
+ `${process.env["ProgramFiles(x86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
53
+ `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
54
+ ],
55
+ };
56
+ export function findChromePath() {
57
+ // CHROME_PATH env override
58
+ const envPath = process.env.CHROME_PATH;
59
+ if (envPath) {
60
+ if (existsSync(envPath))
61
+ return envPath;
62
+ return null;
63
+ }
64
+ const platform = process.platform;
65
+ const candidates = CHROME_PATHS[platform];
66
+ if (!candidates)
67
+ return null;
68
+ if (platform === "linux") {
69
+ // Linux: executable names — resolve via `which`
70
+ for (const name of candidates) {
71
+ try {
72
+ const resolved = execFileSync("which", [name], {
73
+ encoding: "utf-8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ }).trim();
76
+ if (resolved)
77
+ return resolved;
78
+ }
79
+ catch {
80
+ // not found, try next
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ // macOS / Windows: absolute paths — check existence
86
+ for (const p of candidates) {
87
+ if (existsSync(p))
88
+ return p;
89
+ }
90
+ return null;
91
+ }
92
+ // ── Chrome Spawn with CDP-Pipe (Task 2) ───────────────────────────────
93
+ const CHROME_FLAGS = [
94
+ "--remote-debugging-pipe",
95
+ "--no-first-run",
96
+ "--no-default-browser-check",
97
+ "--disable-default-apps",
98
+ "--disable-extensions",
99
+ "--disable-background-networking",
100
+ "--disable-background-timer-throttling",
101
+ "--disable-backgrounding-occluded-windows",
102
+ "--disable-renderer-backgrounding",
103
+ "--enable-features=CDPScreenshotNewSurface",
104
+ "--disable-sync",
105
+ "--mute-audio",
106
+ ];
107
+ export async function launchChrome(options) {
108
+ const chromePath = findChromePath();
109
+ if (!chromePath) {
110
+ throw new Error("Chrome not found. Install Chrome or set CHROME_PATH environment variable.");
111
+ }
112
+ let userDataDir;
113
+ let tmpDir;
114
+ if (options?.profilePath) {
115
+ // Validate that the profile path exists
116
+ if (!existsSync(options.profilePath)) {
117
+ throw new Error(`Chrome profile path does not exist: ${options.profilePath}`);
118
+ }
119
+ userDataDir = options.profilePath;
120
+ // No tmpDir — profile directory must NEVER be deleted
121
+ }
122
+ else {
123
+ // Default: isolated temp profile
124
+ tmpDir = join(tmpdir(), `silbercuechrome-${randomBytes(4).toString("hex")}`);
125
+ await mkdir(tmpDir, { recursive: true });
126
+ userDataDir = tmpDir;
127
+ }
128
+ const flags = [...CHROME_FLAGS, `--user-data-dir=${userDataDir}`];
129
+ if (options?.headless !== false) {
130
+ flags.unshift("--headless");
131
+ }
132
+ debug("Spawning Chrome: %s %s", chromePath, flags.join(" "));
133
+ const child = spawn(chromePath, flags, {
134
+ stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"],
135
+ });
136
+ try {
137
+ const cdpReadable = child.stdio[4];
138
+ const cdpWritable = child.stdio[3];
139
+ const transport = new PipeTransport(cdpReadable, cdpWritable);
140
+ const cdpClient = new CdpClient(transport);
141
+ // Wait for Chrome to be ready — Browser.getVersion must succeed
142
+ // B1: 5s pipe startup timeout (NFR11/NFR14 compliance)
143
+ await Promise.race([
144
+ cdpClient.send("Browser.getVersion"),
145
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Chrome startup timed out after 5s")), 5_000)),
146
+ ]);
147
+ return { cdpClient, transport, process: child, transportType: "pipe" };
148
+ }
149
+ catch (err) {
150
+ // Cleanup on failure — only delete temp directories, NEVER profile directories
151
+ child.kill();
152
+ if (tmpDir) {
153
+ await rm(tmpDir, { recursive: true, force: true }).catch(() => { });
154
+ }
155
+ throw err;
156
+ }
157
+ }
158
+ async function fetchJsonVersion(port, timeoutMs = 500) {
159
+ return new Promise((resolve, reject) => {
160
+ let settled = false;
161
+ const timer = setTimeout(() => {
162
+ if (settled)
163
+ return;
164
+ settled = true;
165
+ req.destroy();
166
+ reject(new Error(`/json/version request timed out after ${timeoutMs}ms`));
167
+ }, timeoutMs);
168
+ const req = httpRequest({
169
+ hostname: "127.0.0.1",
170
+ port,
171
+ path: "/json/version",
172
+ method: "GET",
173
+ timeout: timeoutMs,
174
+ }, (res) => {
175
+ if (settled)
176
+ return;
177
+ if (res.statusCode !== 200) {
178
+ clearTimeout(timer);
179
+ settled = true;
180
+ reject(new Error(`/json/version returned HTTP ${res.statusCode}`));
181
+ res.resume();
182
+ return;
183
+ }
184
+ let body = "";
185
+ res.on("data", (chunk) => {
186
+ body += chunk.toString();
187
+ });
188
+ res.on("end", () => {
189
+ if (settled)
190
+ return;
191
+ clearTimeout(timer);
192
+ settled = true;
193
+ try {
194
+ const parsed = JSON.parse(body);
195
+ if (typeof parsed !== "object" || parsed === null) {
196
+ reject(new Error("/json/version returned invalid JSON"));
197
+ return;
198
+ }
199
+ resolve(parsed);
200
+ }
201
+ catch {
202
+ reject(new Error("/json/version returned invalid JSON"));
203
+ }
204
+ });
205
+ });
206
+ req.on("error", (err) => {
207
+ if (settled)
208
+ return;
209
+ clearTimeout(timer);
210
+ settled = true;
211
+ reject(err);
212
+ });
213
+ req.end();
214
+ });
215
+ }
216
+ // ── ChromeConnection (Task 4 + Task 6) ────────────────────────────────
217
+ export class ChromeConnection {
218
+ transportType;
219
+ status = "connected";
220
+ _exitHandler = null;
221
+ _closed = false;
222
+ // Reconnect fields (Story 5.2)
223
+ _cdpClient;
224
+ _transport;
225
+ _childProcess;
226
+ _tmpDir;
227
+ _reconnecting = false;
228
+ _onReconnect = null;
229
+ _headless;
230
+ _port;
231
+ _profilePath;
232
+ constructor(cdpClient, transport, transportType, childProcess, tmpDir, _launcher, port, headless, profilePath) {
233
+ this.transportType = transportType;
234
+ this._cdpClient = cdpClient;
235
+ this._transport = transport;
236
+ this._childProcess = childProcess;
237
+ this._tmpDir = tmpDir;
238
+ this._port = port ?? 9222;
239
+ this._headless = headless ?? false;
240
+ this._profilePath = profilePath;
241
+ // C1 fix: Passive status tracking via CdpClient.onClose —
242
+ // detects unexpected transport close (WebSocket drop, pipe break)
243
+ this._setupOnClose(cdpClient);
244
+ if (this._childProcess) {
245
+ this._setupChildProcessHandlers(this._childProcess);
246
+ }
247
+ }
248
+ get cdpClient() {
249
+ return this._cdpClient;
250
+ }
251
+ get transport() {
252
+ return this._transport;
253
+ }
254
+ get childProcess() {
255
+ return this._childProcess;
256
+ }
257
+ get headless() {
258
+ return this._headless;
259
+ }
260
+ /** Register a callback to be invoked after successful reconnect for re-wiring */
261
+ onReconnect(callback) {
262
+ this._onReconnect = callback;
263
+ }
264
+ /**
265
+ * Attempt to reconnect to Chrome with exponential backoff.
266
+ * BUG-004 fix: No race window (_reconnecting stays true during entire loop),
267
+ * failed transports are cleaned up, and onReconnect errors don't short-circuit.
268
+ * Returns true if reconnect succeeded, false otherwise.
269
+ */
270
+ async reconnect() {
271
+ if (this._reconnecting || this._closed)
272
+ return false;
273
+ this._reconnecting = true;
274
+ this.status = "reconnecting";
275
+ // Best-effort close old client/transport
276
+ try {
277
+ await this._cdpClient.close();
278
+ }
279
+ catch {
280
+ /* best-effort */
281
+ }
282
+ const maxAttempts = 5;
283
+ const baseDelay = 500; // Exponential backoff: 500, 1000, 2000, 4000ms
284
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
285
+ // B2: Check _closed inside the loop so close() during reconnect aborts immediately
286
+ if (this._closed) {
287
+ this._reconnecting = false;
288
+ return false;
289
+ }
290
+ if (attempt > 1) {
291
+ const delay = baseDelay * Math.pow(2, attempt - 2);
292
+ await new Promise((r) => setTimeout(r, delay));
293
+ // B2: Re-check after the pause — close() may have been called while waiting
294
+ if (this._closed) {
295
+ this._reconnecting = false;
296
+ return false;
297
+ }
298
+ // BUG-004: Clean up transport from previous failed attempt to prevent leaks
299
+ try {
300
+ await this._cdpClient.close();
301
+ }
302
+ catch {
303
+ /* best-effort */
304
+ }
305
+ }
306
+ try {
307
+ if (this.transportType === "websocket") {
308
+ // WebSocket reconnect: Chrome is still running, reconnect to same port
309
+ // B1: fetchJsonVersion uses 500ms default, WebSocket connect 2s
310
+ const versionInfo = await fetchJsonVersion(this._port);
311
+ if (!versionInfo.webSocketDebuggerUrl) {
312
+ throw new Error("Missing webSocketDebuggerUrl");
313
+ }
314
+ const wsUrl = versionInfo.webSocketDebuggerUrl;
315
+ const newTransport = await WebSocketTransport.connect(wsUrl, { timeoutMs: 2000 });
316
+ const newClient = new CdpClient(newTransport);
317
+ await newClient.send("Browser.getVersion");
318
+ this._transport = newTransport;
319
+ this._cdpClient = newClient;
320
+ }
321
+ else {
322
+ // Pipe reconnect: Chrome process is dead, relaunch
323
+ const result = await launchChrome({ headless: this._headless, profilePath: this._profilePath });
324
+ this._transport = result.transport;
325
+ this._cdpClient = result.cdpClient;
326
+ // Clean up old child process handlers
327
+ if (this._exitHandler) {
328
+ globalThis.process.removeListener("exit", this._exitHandler);
329
+ this._exitHandler = null;
330
+ }
331
+ this._childProcess = result.process;
332
+ if (this._profilePath) {
333
+ // Profile path: no tmpDir — profile directory must NEVER be deleted
334
+ this._tmpDir = undefined;
335
+ }
336
+ else {
337
+ const tmpDirFlag = result.process.spawnargs.find((a) => a.startsWith("--user-data-dir="));
338
+ this._tmpDir = tmpDirFlag?.split("=")[1];
339
+ }
340
+ // Setup handlers for new child process
341
+ this._setupChildProcessHandlers(this._childProcess);
342
+ }
343
+ // Setup onClose for the new CdpClient to detect future disconnects
344
+ this._setupOnClose(this._cdpClient);
345
+ // Invoke onReconnect callback BEFORE setting status to connected.
346
+ // BUG-004 fix: If callback fails, DON'T throw — let the loop continue
347
+ // to the next attempt. _reconnecting stays true (no race window).
348
+ if (this._onReconnect) {
349
+ await this._onReconnect(this);
350
+ }
351
+ this.status = "connected";
352
+ this._reconnecting = false;
353
+ debug("Reconnect succeeded on attempt %d", attempt);
354
+ return true;
355
+ }
356
+ catch (err) {
357
+ const msg = err instanceof Error ? err.message : String(err);
358
+ debug("Reconnect attempt %d/%d failed: %s", attempt, maxAttempts, msg);
359
+ }
360
+ }
361
+ this.status = "disconnected";
362
+ this._reconnecting = false;
363
+ debug("All %d reconnect attempts failed", maxAttempts);
364
+ return false;
365
+ }
366
+ async close() {
367
+ if (this._closed)
368
+ return;
369
+ this._closed = true;
370
+ this.status = "disconnected";
371
+ // Remove process listeners to prevent accumulation
372
+ if (this._exitHandler)
373
+ globalThis.process.removeListener("exit", this._exitHandler);
374
+ // Close CDP client (which closes the transport)
375
+ await this._cdpClient.close();
376
+ // Terminate child process if we launched it
377
+ if (this._childProcess && !this._childProcess.killed) {
378
+ if (globalThis.process.platform === "win32") {
379
+ // H3 fix: On Windows, kill() sends taskkill — no SIGTERM/SIGKILL distinction
380
+ this._childProcess.kill();
381
+ }
382
+ else {
383
+ // POSIX: SIGTERM first, force SIGKILL after 5s
384
+ this._childProcess.kill("SIGTERM");
385
+ const forceTimer = setTimeout(() => {
386
+ if (!this._childProcess.killed) {
387
+ this._childProcess.kill("SIGKILL");
388
+ }
389
+ }, 5000);
390
+ forceTimer.unref();
391
+ this._childProcess.once("exit", () => clearTimeout(forceTimer));
392
+ }
393
+ }
394
+ // Clean up tmp user-data-dir
395
+ if (this._tmpDir) {
396
+ await rm(this._tmpDir, { recursive: true, force: true }).catch(() => { });
397
+ }
398
+ }
399
+ /** Register onClose callback on a CdpClient to trigger reconnect on unexpected disconnect.
400
+ * BUG-004 fix: fired-flag prevents handler accumulation across reconnect attempts. */
401
+ _setupOnClose(client) {
402
+ let fired = false;
403
+ client.onClose(() => {
404
+ if (fired || this._closed)
405
+ return;
406
+ fired = true;
407
+ this.status = "disconnected";
408
+ // Fire-and-forget reconnect
409
+ this.reconnect().catch((err) => {
410
+ debug("Reconnect error: %s", err instanceof Error ? err.message : String(err));
411
+ });
412
+ });
413
+ }
414
+ /** Setup child process exit handler and global exit cleanup */
415
+ _setupChildProcessHandlers(child) {
416
+ // Track status on child process exit + trigger reconnect
417
+ child.on("exit", () => {
418
+ if (this._closed)
419
+ return; // deliberate shutdown
420
+ this.status = "disconnected";
421
+ debug("Chrome process exited, attempting relaunch...");
422
+ this.reconnect().catch((err) => {
423
+ debug("Reconnect after crash error: %s", err instanceof Error ? err.message : String(err));
424
+ });
425
+ });
426
+ // H4 fix: Only register 'exit' handler for sync cleanup
427
+ this._exitHandler = () => {
428
+ if (this._closed)
429
+ return;
430
+ this._childProcess?.kill();
431
+ };
432
+ globalThis.process.on("exit", this._exitHandler);
433
+ }
434
+ }
435
+ // ── ChromeLauncher (Task 4) ───────────────────────────────────────────
436
+ export class ChromeLauncher {
437
+ _port;
438
+ _autoLaunch;
439
+ _headless;
440
+ _profilePath;
441
+ constructor(options) {
442
+ this._port = options?.port ?? 9222;
443
+ this._autoLaunch = options?.autoLaunch ?? true;
444
+ this._headless = options?.headless ?? false;
445
+ this._profilePath = options?.profilePath;
446
+ }
447
+ async connect() {
448
+ // 1. Try WebSocket to existing Chrome
449
+ debug("Trying WebSocket on port %d...", this._port);
450
+ let wsError;
451
+ try {
452
+ return await this._connectViaWebSocket(this._port);
453
+ }
454
+ catch (err) {
455
+ wsError = err instanceof Error ? err : new Error(String(err));
456
+ debug("WebSocket failed: %s", wsError.message);
457
+ }
458
+ // 2. Auto-launch if enabled
459
+ // C2 fix: preserve original error for better diagnostics
460
+ if (!this._autoLaunch) {
461
+ throw wsError;
462
+ }
463
+ debug("Launching Chrome...");
464
+ const result = await launchChrome({ headless: this._headless, profilePath: this._profilePath });
465
+ // Extract tmpDir from the spawn args — only for temp profiles (no profilePath)
466
+ let tmpDir;
467
+ if (!this._profilePath) {
468
+ const tmpDirFlag = result.process.spawnargs.find((a) => a.startsWith("--user-data-dir="));
469
+ tmpDir = tmpDirFlag?.split("=")[1];
470
+ }
471
+ const connection = new ChromeConnection(result.cdpClient, result.transport, result.transportType, result.process, tmpDir, this, this._port, this._headless, this._profilePath);
472
+ debug("Connected via pipe");
473
+ return connection;
474
+ }
475
+ async _connectViaWebSocket(port) {
476
+ const versionInfo = await fetchJsonVersion(port);
477
+ if (!versionInfo.webSocketDebuggerUrl) {
478
+ throw new Error("/json/version response missing webSocketDebuggerUrl field");
479
+ }
480
+ const wsUrl = versionInfo.webSocketDebuggerUrl;
481
+ const transport = await WebSocketTransport.connect(wsUrl, {
482
+ timeoutMs: 5000,
483
+ });
484
+ const cdpClient = new CdpClient(transport);
485
+ // Verify connection
486
+ await cdpClient.send("Browser.getVersion");
487
+ // Auto-detect headless from /json/version Browser field.
488
+ // Headed Chrome reports "Chrome/...", headless reports "HeadlessChrome/...".
489
+ const browserString = typeof versionInfo.Browser === "string" ? versionInfo.Browser : "";
490
+ const detectedHeadless = browserString.includes("HeadlessChrome");
491
+ if (detectedHeadless !== this._headless) {
492
+ debug("Headless auto-detected=%s (Browser: %s), overriding env setting=%s", detectedHeadless, browserString, this._headless);
493
+ }
494
+ if (this._profilePath) {
495
+ debug("Connected via WebSocket to existing Chrome — profilePath ignored (only affects Auto-Launch)");
496
+ }
497
+ else {
498
+ debug("Connected via WebSocket");
499
+ }
500
+ return new ChromeConnection(cdpClient, transport, "websocket", undefined, undefined, this, port, detectedHeadless);
501
+ }
502
+ }
@@ -0,0 +1,53 @@
1
+ import type { CdpClient } from "./cdp-client.js";
2
+ export interface ConsoleLogEntry {
3
+ level: "info" | "warning" | "error" | "debug";
4
+ text: string;
5
+ timestamp: number;
6
+ source: "console" | "exception";
7
+ }
8
+ export interface ConsoleCollectorOptions {
9
+ maxEntries?: number;
10
+ }
11
+ export declare class ConsoleCollector {
12
+ private _buffer;
13
+ private _maxEntries;
14
+ private _cdpClient;
15
+ private _sessionId;
16
+ private _consoleCallback;
17
+ private _exceptionCallback;
18
+ private _initialized;
19
+ constructor(cdpClient: CdpClient, sessionId: string, options?: ConsoleCollectorOptions);
20
+ /**
21
+ * Start listening for Runtime.consoleAPICalled and Runtime.exceptionThrown events.
22
+ */
23
+ init(): void;
24
+ /**
25
+ * Remove event listeners. Buffer is preserved.
26
+ */
27
+ detach(): void;
28
+ /**
29
+ * Re-initialize after reconnect or tab switch.
30
+ * Buffer is cleared on reinit (new page context = new logs).
31
+ */
32
+ reinit(cdpClient: CdpClient, sessionId: string): void;
33
+ /**
34
+ * Return a copy of all buffered log entries.
35
+ */
36
+ getAll(): ConsoleLogEntry[];
37
+ /**
38
+ * Return filtered log entries. Both filters are combined with AND.
39
+ * Throws if the regex pattern is invalid.
40
+ */
41
+ getFiltered(level?: string, pattern?: string): ConsoleLogEntry[];
42
+ /**
43
+ * Clear the log buffer.
44
+ */
45
+ clear(): void;
46
+ /**
47
+ * Current number of entries in the buffer.
48
+ */
49
+ get count(): number;
50
+ private _pushEntry;
51
+ private _onConsoleAPICalled;
52
+ private _onExceptionThrown;
53
+ }
@@ -0,0 +1,147 @@
1
+ import { debug } from "./debug.js";
2
+ // --- Level Mapping ---
3
+ const LEVEL_MAP = {
4
+ log: "info",
5
+ info: "info",
6
+ debug: "debug",
7
+ warning: "warning",
8
+ error: "error",
9
+ assert: "error",
10
+ trace: "info",
11
+ };
12
+ function mapLevel(type) {
13
+ return LEVEL_MAP[type] ?? "info";
14
+ }
15
+ function remoteObjectToString(obj) {
16
+ if (obj.value !== undefined)
17
+ return String(obj.value);
18
+ if (obj.unserializableValue)
19
+ return obj.unserializableValue;
20
+ if (obj.description)
21
+ return obj.description;
22
+ return String(obj.type);
23
+ }
24
+ // --- ConsoleCollector ---
25
+ export class ConsoleCollector {
26
+ _buffer = [];
27
+ _maxEntries;
28
+ _cdpClient;
29
+ _sessionId;
30
+ _consoleCallback = null;
31
+ _exceptionCallback = null;
32
+ _initialized = false;
33
+ constructor(cdpClient, sessionId, options) {
34
+ this._cdpClient = cdpClient;
35
+ this._sessionId = sessionId;
36
+ this._maxEntries = options?.maxEntries ?? 1000;
37
+ }
38
+ /**
39
+ * Start listening for Runtime.consoleAPICalled and Runtime.exceptionThrown events.
40
+ */
41
+ init() {
42
+ if (this._initialized)
43
+ return;
44
+ this._initialized = true;
45
+ this._consoleCallback = (params) => {
46
+ this._onConsoleAPICalled(params);
47
+ };
48
+ this._exceptionCallback = (params) => {
49
+ this._onExceptionThrown(params);
50
+ };
51
+ this._cdpClient.on("Runtime.consoleAPICalled", this._consoleCallback, this._sessionId);
52
+ this._cdpClient.on("Runtime.exceptionThrown", this._exceptionCallback, this._sessionId);
53
+ debug("ConsoleCollector initialized on session %s", this._sessionId);
54
+ }
55
+ /**
56
+ * Remove event listeners. Buffer is preserved.
57
+ */
58
+ detach() {
59
+ this._initialized = false;
60
+ if (this._consoleCallback) {
61
+ this._cdpClient.off("Runtime.consoleAPICalled", this._consoleCallback);
62
+ this._consoleCallback = null;
63
+ }
64
+ if (this._exceptionCallback) {
65
+ this._cdpClient.off("Runtime.exceptionThrown", this._exceptionCallback);
66
+ this._exceptionCallback = null;
67
+ }
68
+ debug("ConsoleCollector detached");
69
+ }
70
+ /**
71
+ * Re-initialize after reconnect or tab switch.
72
+ * Buffer is cleared on reinit (new page context = new logs).
73
+ */
74
+ reinit(cdpClient, sessionId) {
75
+ this.detach();
76
+ this._cdpClient = cdpClient;
77
+ this._sessionId = sessionId;
78
+ this._buffer = [];
79
+ this.init();
80
+ }
81
+ /**
82
+ * Return a copy of all buffered log entries.
83
+ */
84
+ getAll() {
85
+ return [...this._buffer];
86
+ }
87
+ /**
88
+ * Return filtered log entries. Both filters are combined with AND.
89
+ * Throws if the regex pattern is invalid.
90
+ */
91
+ getFiltered(level, pattern) {
92
+ if (!level && !pattern)
93
+ return this.getAll();
94
+ let regex;
95
+ if (pattern) {
96
+ regex = new RegExp(pattern);
97
+ }
98
+ return this._buffer.filter((entry) => {
99
+ if (level && entry.level !== level)
100
+ return false;
101
+ if (regex && !regex.test(entry.text))
102
+ return false;
103
+ return true;
104
+ });
105
+ }
106
+ /**
107
+ * Clear the log buffer.
108
+ */
109
+ clear() {
110
+ this._buffer = [];
111
+ }
112
+ /**
113
+ * Current number of entries in the buffer.
114
+ */
115
+ get count() {
116
+ return this._buffer.length;
117
+ }
118
+ // --- Internal ---
119
+ _pushEntry(entry) {
120
+ if (this._buffer.length >= this._maxEntries) {
121
+ this._buffer.shift();
122
+ }
123
+ this._buffer.push(entry);
124
+ }
125
+ _onConsoleAPICalled(params) {
126
+ const p = params;
127
+ const text = (p.args ?? []).map(remoteObjectToString).join(" ");
128
+ this._pushEntry({
129
+ level: mapLevel(p.type ?? "log"),
130
+ text,
131
+ timestamp: performance.now(),
132
+ source: "console",
133
+ });
134
+ }
135
+ _onExceptionThrown(params) {
136
+ const p = params;
137
+ const text = p.exceptionDetails?.exception?.description ??
138
+ p.exceptionDetails?.text ??
139
+ "Unknown exception";
140
+ this._pushEntry({
141
+ level: "error",
142
+ text,
143
+ timestamp: performance.now(),
144
+ source: "exception",
145
+ });
146
+ }
147
+ }