@narumitw/pi-chrome-devtools 0.13.0 → 0.13.1

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 CHANGED
@@ -174,8 +174,7 @@ instead of enabling tools by itself. A valid saved selection is restored on Pi s
174
174
  Compatibility: older versions used `pi-chrome-devtools-settings.json`. During the migration
175
175
  window, a legacy-only file is automatically migrated to `pi-chrome-devtools.json` with a warning.
176
176
  If both files exist, `pi-chrome-devtools.json` wins and the legacy file is ignored. The legacy
177
- filename is deprecated and planned for removal after at least one minor release cycle, preferably
178
- 4–8 weeks, or at the next major release.
177
+ filename is deprecated and will be removed in a future major release.
179
178
 
180
179
  ## 🧠 Use cases
181
180
 
@@ -190,14 +189,15 @@ filename is deprecated and planned for removal after at least one minor release
190
189
  ```txt
191
190
  extensions/pi-chrome-devtools/
192
191
  ├── src/
193
- │ └── chrome-devtools.ts
192
+ │ ├── chrome-devtools.ts # Pi entrypoint and command orchestration
193
+ │ └── *.ts # Package-local browser, CDP, tool, and storage modules
194
194
  ├── README.md
195
195
  ├── LICENSE
196
196
  ├── tsconfig.json
197
197
  └── package.json
198
198
  ```
199
199
 
200
- The package exposes its Pi extension through `package.json`:
200
+ Only `chrome-devtools.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
201
201
 
202
202
  ```json
203
203
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-chrome-devtools",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Pi extension that exposes Chrome DevTools Protocol tools.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,659 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, delimiter, isAbsolute, join, resolve } from "node:path";
6
+ import {
7
+ BROWSER_SHUTDOWN_WAIT_MS,
8
+ DEFAULT_ENDPOINT_RETRY_MS,
9
+ DEFAULT_ENDPOINT_WAIT_MS,
10
+ DEFAULT_HOST,
11
+ DEFAULT_HTTP_TIMEOUT_MS,
12
+ DEVTOOLS_ACTIVE_PORT_FILE,
13
+ MANAGED_BROWSER_PROFILE_PREFIX,
14
+ state,
15
+ type BrowserCandidate,
16
+ type BrowserCandidateDefinition,
17
+ type DevToolsPage,
18
+ type ManagedBrowser,
19
+ } from "./runtime.js";
20
+
21
+ function formatError(error: unknown) {
22
+ return error instanceof Error ? error.message : String(error);
23
+ }
24
+
25
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
26
+ return error instanceof Error && "code" in error;
27
+ }
28
+
29
+ function normalizePathForComparison(value: string) {
30
+ return process.platform === "win32" ? value.toLowerCase() : value;
31
+ }
32
+
33
+ export async function ensureDevToolsEndpoint(waitMs = DEFAULT_ENDPOINT_WAIT_MS) {
34
+ if (canAutoLaunchBrowser()) {
35
+ try {
36
+ await withEndpointRetry(() => fetchDevToolsJson<unknown>("/json/version"), waitMs);
37
+ return;
38
+ } catch (error) {
39
+ if (shouldAutoLaunchAfterEndpointError(error)) {
40
+ await ensureManagedBrowserLaunched(waitMs);
41
+ return;
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ try {
48
+ await fetchDevToolsJson<unknown>("/json/version");
49
+ } catch (error) {
50
+ if (isRetryableEndpointError(error)) return;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ async function ensureManagedBrowserLaunched(waitMs: number) {
56
+ if (state.launchPromise) return state.launchPromise;
57
+ if (state.managedBrowser && !state.managedBrowser.exited && state.managedBrowser.ready) return;
58
+ if (state.managedBrowser) {
59
+ await shutdownManagedBrowser(state.managedBrowser, { awaitLaunch: false });
60
+ }
61
+ throwIfBrowserLaunchCancelled();
62
+
63
+ state.launchPromise = launchManagedBrowser(waitMs).finally(() => {
64
+ state.launchPromise = undefined;
65
+ });
66
+ return state.launchPromise;
67
+ }
68
+
69
+ async function launchManagedBrowser(waitMs: number) {
70
+ throwIfBrowserLaunchCancelled();
71
+ const candidateDefinitions = browserCandidateDefinitions();
72
+ const candidates = await resolveBrowserCandidates(candidateDefinitions);
73
+ throwIfBrowserLaunchCancelled();
74
+ state.lastLaunchAttempt = {
75
+ candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
76
+ mode: state.portConfigured ? "explicit-port" : "dynamic-port",
77
+ };
78
+
79
+ if (candidates.length === 0) {
80
+ throw new DevToolsEndpointError(noBrowserCandidateMessage(candidateDefinitions));
81
+ }
82
+
83
+ let lastError: unknown;
84
+ for (const candidate of candidates) {
85
+ throwIfBrowserLaunchCancelled();
86
+ try {
87
+ await launchBrowserCandidate(candidate, waitMs);
88
+ state.lastLaunchAttempt = {
89
+ ...state.lastLaunchAttempt,
90
+ selectedCandidate: formatBrowserCandidate(candidate),
91
+ userDataDir: state.managedBrowser?.userDataDir,
92
+ };
93
+ return;
94
+ } catch (error) {
95
+ lastError = error;
96
+ state.lastLaunchAttempt = {
97
+ ...state.lastLaunchAttempt,
98
+ lastError: formatError(error),
99
+ };
100
+ }
101
+ }
102
+
103
+ throw new DevToolsEndpointError(
104
+ [
105
+ "Unable to auto-launch a Chromium-family browser for Chrome DevTools.",
106
+ `Tried: ${candidates.map(formatBrowserCandidate).join(", ")}`,
107
+ lastError ? `Last error: ${formatError(lastError)}` : undefined,
108
+ launchHint(),
109
+ endpointConfigHint(),
110
+ ]
111
+ .filter(Boolean)
112
+ .join("\n"),
113
+ );
114
+ }
115
+
116
+ async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: number) {
117
+ throwIfBrowserLaunchCancelled();
118
+ const userDataDir = await mkdtemp(join(tmpdir(), MANAGED_BROWSER_PROFILE_PREFIX));
119
+ let managedBrowser: ManagedBrowser | undefined;
120
+ try {
121
+ const portArgument = state.portConfigured ? String(state.port) : "0";
122
+ const args = [
123
+ `--remote-debugging-port=${portArgument}`,
124
+ `--user-data-dir=${userDataDir}`,
125
+ "--no-first-run",
126
+ "--no-default-browser-check",
127
+ "about:blank",
128
+ ];
129
+ throwIfBrowserLaunchCancelled();
130
+ const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
131
+ const launchedBrowser: ManagedBrowser = { process: child, userDataDir, exited: false, ready: false };
132
+ managedBrowser = launchedBrowser;
133
+ state.managedBrowser = launchedBrowser;
134
+
135
+ child.once("exit", () => {
136
+ launchedBrowser.exited = true;
137
+ launchedBrowser.ready = false;
138
+ if (!state.portConfigured && launchedBrowser.port === state.port) {
139
+ state.port = state.configuredPort;
140
+ }
141
+ });
142
+
143
+ await waitForBrowserSpawn(child);
144
+ if (state.portConfigured) {
145
+ launchedBrowser.port = state.port;
146
+ } else {
147
+ launchedBrowser.port = await readManagedBrowserPort(userDataDir, launchedBrowser, waitMs);
148
+ state.port = launchedBrowser.port;
149
+ }
150
+ await waitForDevToolsEndpoint(waitMs, launchedBrowser);
151
+ launchedBrowser.ready = true;
152
+ } catch (error) {
153
+ if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false });
154
+ else await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ function waitForBrowserSpawn(child: ChildProcess) {
160
+ return new Promise<void>((resolve, reject) => {
161
+ let settled = false;
162
+ const settle = (callback: () => void) => {
163
+ if (settled) return;
164
+ settled = true;
165
+ child.off("error", onError);
166
+ child.off("spawn", onSpawn);
167
+ callback();
168
+ };
169
+ const onError = (error: Error) => settle(() => reject(error));
170
+ const onSpawn = () => settle(resolve);
171
+ child.once("error", onError);
172
+ child.once("spawn", onSpawn);
173
+ });
174
+ }
175
+
176
+ async function readManagedBrowserPort(
177
+ userDataDir: string,
178
+ managedBrowser: ManagedBrowser,
179
+ waitMs: number,
180
+ ) {
181
+ const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
182
+ const deadline = Date.now() + waitMs;
183
+ while (true) {
184
+ throwIfManagedBrowserExited(managedBrowser);
185
+ const text = await readFile(activePortFile, "utf8").catch((error: unknown) => {
186
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
187
+ throw error;
188
+ });
189
+ const portText = text?.split(/\r?\n/, 1)[0]?.trim();
190
+ const port = Number(portText);
191
+ if (Number.isInteger(port) && port > 0) return port;
192
+
193
+ throwIfBrowserLaunchCancelled();
194
+ const remainingMs = deadline - Date.now();
195
+ if (remainingMs <= 0) {
196
+ throw new DevToolsEndpointError(
197
+ [
198
+ "Timed out waiting for auto-launched browser DevToolsActivePort.",
199
+ `Expected file: ${activePortFile}`,
200
+ launchHint(),
201
+ ].join("\n"),
202
+ );
203
+ }
204
+ await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
205
+ }
206
+ }
207
+
208
+ async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBrowser) {
209
+ const deadline = Date.now() + waitMs;
210
+ while (true) {
211
+ throwIfManagedBrowserExited(managedBrowser);
212
+ try {
213
+ await fetchDevToolsJson<unknown>("/json/version");
214
+ return;
215
+ } catch (error) {
216
+ if (!isRetryableEndpointError(error)) throw error;
217
+ }
218
+
219
+ throwIfBrowserLaunchCancelled();
220
+ const remainingMs = deadline - Date.now();
221
+ if (remainingMs <= 0) {
222
+ throw new DevToolsEndpointError(
223
+ [
224
+ `Timed out waiting for auto-launched browser at ${devToolsEndpoint()}.`,
225
+ launchHint(),
226
+ ].join("\n"),
227
+ );
228
+ }
229
+ await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
230
+ }
231
+ }
232
+
233
+ function throwIfManagedBrowserExited(managedBrowser: ManagedBrowser) {
234
+ if (!managedBrowser.exited) return;
235
+ throw new DevToolsEndpointError("Auto-launched browser exited before DevTools became available.");
236
+ }
237
+
238
+ function throwIfBrowserLaunchCancelled() {
239
+ if (!state.shuttingDown) return;
240
+ throw new DevToolsEndpointError("Chrome DevTools browser launch cancelled during shutdown.");
241
+ }
242
+
243
+ export async function shutdownManagedBrowser(
244
+ managedBrowser = state.managedBrowser,
245
+ options: { awaitLaunch?: boolean; cancelLaunch?: boolean } = {},
246
+ ) {
247
+ if (options.cancelLaunch) state.shuttingDown = true;
248
+ if (options.awaitLaunch !== false) {
249
+ await state.launchPromise?.catch(() => undefined);
250
+ managedBrowser = managedBrowser ?? state.managedBrowser;
251
+ }
252
+ if (!managedBrowser) return;
253
+ if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
254
+
255
+ if (!managedBrowser.exited) {
256
+ killManagedBrowserProcess(managedBrowser);
257
+ await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(() => {
258
+ killManagedBrowserProcess(managedBrowser, "SIGKILL");
259
+ });
260
+ }
261
+ await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
262
+ if (!state.portConfigured && managedBrowser.port === state.port) state.port = state.configuredPort;
263
+ }
264
+
265
+ function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
266
+ try {
267
+ managedBrowser.process.kill(signal);
268
+ } catch {
269
+ // Best-effort shutdown: the browser may have already exited or failed to spawn.
270
+ }
271
+ }
272
+
273
+ function waitForManagedBrowserExit(managedBrowser: ManagedBrowser, waitMs: number) {
274
+ if (managedBrowser.exited) return Promise.resolve();
275
+ return new Promise<void>((resolve, reject) => {
276
+ const settle = (callback: () => void) => {
277
+ clearTimeout(timeout);
278
+ managedBrowser.process.off("exit", onExitOrClose);
279
+ managedBrowser.process.off("close", onExitOrClose);
280
+ callback();
281
+ };
282
+ const onExitOrClose = () => {
283
+ managedBrowser.exited = true;
284
+ settle(resolve);
285
+ };
286
+ const timeout = setTimeout(
287
+ () => settle(() => reject(new Error("Timed out waiting for browser shutdown."))),
288
+ waitMs,
289
+ );
290
+ managedBrowser.process.once("exit", onExitOrClose);
291
+ managedBrowser.process.once("close", onExitOrClose);
292
+ });
293
+ }
294
+
295
+ export async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
296
+ const url = `${devToolsEndpoint()}${path}`;
297
+ let response: Response;
298
+ try {
299
+ response = await fetch(url, {
300
+ ...init,
301
+ signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
302
+ });
303
+ } catch (error) {
304
+ throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
305
+ launchable: true,
306
+ retryable: true,
307
+ });
308
+ }
309
+
310
+ if (!response.ok) {
311
+ const body = (await response.text().catch(() => "")).trim();
312
+ const suffix = body ? `: ${body.slice(0, 200)}` : "";
313
+ throw new DevToolsEndpointError(
314
+ [
315
+ `Chrome DevTools endpoint ${url} returned ${response.status} ${response.statusText}${suffix}.`,
316
+ endpointConfigHint(),
317
+ ].join("\n"),
318
+ { retryable: response.status === 429 || response.status >= 500 },
319
+ );
320
+ }
321
+
322
+ try {
323
+ return (await response.json()) as T;
324
+ } catch (error) {
325
+ throw new DevToolsEndpointError(
326
+ [
327
+ `Chrome DevTools endpoint ${url} returned invalid JSON: ${formatError(error)}.`,
328
+ endpointConfigHint(),
329
+ ].join("\n"),
330
+ );
331
+ }
332
+ }
333
+
334
+ export async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs: number) {
335
+ const deadline = Date.now() + waitMs;
336
+ while (true) {
337
+ try {
338
+ return await operation();
339
+ } catch (error) {
340
+ if (!isRetryableEndpointError(error)) throw error;
341
+
342
+ const remainingMs = deadline - Date.now();
343
+ if (remainingMs <= 0) throw error;
344
+
345
+ await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
346
+ }
347
+ }
348
+ }
349
+
350
+ function isRetryableEndpointError(error: unknown) {
351
+ return error instanceof DevToolsEndpointError && error.retryable;
352
+ }
353
+
354
+ function isLaunchableEndpointError(error: unknown) {
355
+ return error instanceof DevToolsEndpointError && error.launchable;
356
+ }
357
+
358
+ function shouldAutoLaunchAfterEndpointError(error: unknown) {
359
+ if (!canAutoLaunchBrowser()) return false;
360
+ if (isLaunchableEndpointError(error)) return true;
361
+
362
+ // After the attach-first attempt (including its retry window, when applicable) fails, treat any
363
+ // DevTools endpoint error on an unpinned port as a conflict we can avoid with a dynamic port.
364
+ return !state.portConfigured && error instanceof DevToolsEndpointError;
365
+ }
366
+
367
+ function canAutoLaunchBrowser() {
368
+ return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
369
+ }
370
+
371
+ function endpointConnectionErrorMessage(error: unknown) {
372
+ const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
373
+ return [
374
+ `Cannot connect to Chrome DevTools endpoint at ${devToolsEndpoint()} (${reason}).`,
375
+ launchHint(),
376
+ endpointConfigHint(),
377
+ ].join("\n");
378
+ }
379
+
380
+ function isTimeoutError(error: unknown) {
381
+ return error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name);
382
+ }
383
+
384
+ export function devToolsEndpoint() {
385
+ return `http://${formatHostForUrl(state.host)}:${state.port}`;
386
+ }
387
+
388
+ export function formatHostForUrl(host: string) {
389
+ if (host.startsWith("[") && host.endsWith("]")) return host;
390
+ return host.includes(":") ? `[${host}]` : host;
391
+ }
392
+
393
+ export function endpointSourceLabel() {
394
+ const hostSource = state.hostConfigured ? "PI_CHROME_DEVTOOLS_HOST" : "default host";
395
+ const portSource = state.portConfigured ? "PI_CHROME_DEVTOOLS_PORT" : "default/dynamic port";
396
+ return `${hostSource}; ${portSource}`;
397
+ }
398
+
399
+ export function launchModeLabel() {
400
+ if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
401
+ if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
402
+ if (state.managedBrowser && !state.managedBrowser.exited) {
403
+ return state.portConfigured ? "auto-launched on explicit port" : "auto-launched on dynamic port";
404
+ }
405
+ return state.portConfigured ? "attach first; auto-launch explicit port" : "attach first; auto-launch dynamic port";
406
+ }
407
+
408
+ export function launchAttemptLines() {
409
+ if (!state.lastLaunchAttempt) return [];
410
+
411
+ const lines = [`Last launch attempt: ${state.lastLaunchAttempt.mode}`];
412
+ if (state.lastLaunchAttempt.selectedCandidate) {
413
+ lines.push(`Launched browser: ${state.lastLaunchAttempt.selectedCandidate}`);
414
+ } else {
415
+ lines.push(`Tried browser candidates: ${state.lastLaunchAttempt.candidateLabels.join(", ")}`);
416
+ }
417
+ if (state.lastLaunchAttempt.userDataDir) {
418
+ lines.push(`Managed browser profile: ${state.lastLaunchAttempt.userDataDir}`);
419
+ }
420
+ if (state.lastLaunchAttempt.lastError) {
421
+ lines.push(`Last launch error: ${state.lastLaunchAttempt.lastError}`);
422
+ }
423
+ return lines;
424
+ }
425
+
426
+ export function launchHint() {
427
+ if (!isLocalDevToolsHost(state.host)) {
428
+ return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
429
+ }
430
+ if (!state.autoLaunchEnabled) {
431
+ return `Auto-launch is disabled. Start a browser manually: ${chromeLaunchCommand()}`;
432
+ }
433
+ const managedMode = state.portConfigured ? `port ${state.port}` : "a dynamic DevTools port";
434
+ return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand()}`;
435
+ }
436
+
437
+ export function browserCandidateHint() {
438
+ return `Browser candidates: ${browserCandidateDefinitions().map((candidate) => candidate.label).join(", ")}`;
439
+ }
440
+
441
+ export function chromeLaunchCommand() {
442
+ const executable = state.browserExecutable ?? defaultManualBrowserExecutable();
443
+ const dataDir =
444
+ process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
445
+ return `${quoteCommandPart(executable)} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
446
+ }
447
+
448
+ function defaultManualBrowserExecutable() {
449
+ return process.platform === "darwin"
450
+ ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
451
+ : process.platform === "win32"
452
+ ? "chrome.exe"
453
+ : "google-chrome";
454
+ }
455
+
456
+ export function quoteCommandPart(value: string) {
457
+ return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
458
+ }
459
+
460
+ export function endpointConfigHint() {
461
+ return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT for a manual endpoint, PI_CHROME_DEVTOOLS_BROWSER to choose an executable, or PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 to disable auto-launch.";
462
+ }
463
+
464
+ export function isLocalDevToolsHost(host: string) {
465
+ const normalizedHost = host.toLowerCase().replace(/^\[(.*)]$/, "$1");
466
+ return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
467
+ }
468
+
469
+ function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
470
+ const explicitCandidate = explicitBrowserCandidateDefinition();
471
+ if (explicitCandidate.length > 0) return explicitCandidate;
472
+
473
+ return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
474
+ }
475
+
476
+ function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
477
+ if (!state.browserExecutable) return [];
478
+ return [{ label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" }];
479
+ }
480
+
481
+ function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
482
+ if (process.platform === "darwin") {
483
+ return [
484
+ {
485
+ label: "Google Chrome",
486
+ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
487
+ source: "wellKnownPath",
488
+ },
489
+ {
490
+ label: "Chromium",
491
+ executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
492
+ source: "wellKnownPath",
493
+ },
494
+ {
495
+ label: "Brave Browser",
496
+ executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
497
+ source: "wellKnownPath",
498
+ },
499
+ {
500
+ label: "Microsoft Edge",
501
+ executable: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
502
+ source: "wellKnownPath",
503
+ },
504
+ ];
505
+ }
506
+
507
+ if (process.platform === "win32") {
508
+ return windowsBrowserCandidateDefinitions();
509
+ }
510
+
511
+ return [
512
+ { label: "Google Chrome", executable: "google-chrome", source: "path" },
513
+ { label: "Google Chrome Stable", executable: "google-chrome-stable", source: "path" },
514
+ { label: "Chromium", executable: "chromium", source: "path" },
515
+ { label: "Chromium Browser", executable: "chromium-browser", source: "path" },
516
+ { label: "Brave Browser", executable: "brave-browser", source: "path" },
517
+ { label: "Brave", executable: "brave", source: "path" },
518
+ { label: "Microsoft Edge", executable: "microsoft-edge", source: "path" },
519
+ { label: "Microsoft Edge Stable", executable: "microsoft-edge-stable", source: "path" },
520
+ ];
521
+ }
522
+
523
+ function windowsBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
524
+ const programFiles = [
525
+ process.env.PROGRAMFILES,
526
+ process.env["PROGRAMFILES(X86)"],
527
+ process.env.LOCALAPPDATA,
528
+ ].filter((value): value is string => typeof value === "string" && value.length > 0);
529
+ const wellKnownPaths = programFiles.flatMap((root) => [
530
+ join(root, "Google", "Chrome", "Application", "chrome.exe"),
531
+ join(root, "Chromium", "Application", "chrome.exe"),
532
+ join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
533
+ join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
534
+ ]);
535
+ return [
536
+ ...wellKnownPaths.map((executable) => ({
537
+ label: browserLabelFromExecutable(executable),
538
+ executable,
539
+ source: "wellKnownPath" as const,
540
+ })),
541
+ { label: "Google Chrome", executable: "chrome.exe", source: "path" },
542
+ { label: "Chromium", executable: "chromium.exe", source: "path" },
543
+ { label: "Brave Browser", executable: "brave.exe", source: "path" },
544
+ { label: "Microsoft Edge", executable: "msedge.exe", source: "path" },
545
+ ];
546
+ }
547
+
548
+ function browserLabelFromExecutable(executable: string) {
549
+ const normalizedExecutable = normalizePathForComparison(executable);
550
+ if (normalizedExecutable.includes("brave")) return "Brave Browser";
551
+ if (normalizedExecutable.includes("edge") || normalizedExecutable.includes("msedge")) {
552
+ return "Microsoft Edge";
553
+ }
554
+ if (normalizedExecutable.includes("chromium")) return "Chromium";
555
+ return "Google Chrome";
556
+ }
557
+
558
+ function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
559
+ const seen = new Set<string>();
560
+ return candidates.filter((candidate) => {
561
+ const key = normalizePathForComparison(candidate.executable);
562
+ if (seen.has(key)) return false;
563
+ seen.add(key);
564
+ return true;
565
+ });
566
+ }
567
+
568
+ async function resolveBrowserCandidates(definitions: BrowserCandidateDefinition[]) {
569
+ const candidates: BrowserCandidate[] = [];
570
+ for (const definition of definitions) {
571
+ const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
572
+ if (!resolvedExecutable) continue;
573
+ candidates.push({ ...definition, resolvedExecutable });
574
+ }
575
+ return uniqueBrowserCandidatesByResolvedPath(candidates);
576
+ }
577
+
578
+ function uniqueBrowserCandidatesByResolvedPath(candidates: BrowserCandidate[]) {
579
+ const seen = new Set<string>();
580
+ return candidates.filter((candidate) => {
581
+ const key = normalizePathForComparison(resolve(candidate.resolvedExecutable));
582
+ if (seen.has(key)) return false;
583
+ seen.add(key);
584
+ return true;
585
+ });
586
+ }
587
+
588
+ async function resolveBrowserExecutable(executable: string) {
589
+ if (hasPathSeparator(executable) || isAbsolute(executable)) {
590
+ const resolvedExecutable = isAbsolute(executable) ? executable : resolve(executable);
591
+ return (await canAccessExecutable(resolvedExecutable)) ? resolvedExecutable : undefined;
592
+ }
593
+
594
+ for (const directory of executableSearchPath()) {
595
+ for (const executableName of executableSearchNames(executable)) {
596
+ const candidate = join(directory, executableName);
597
+ if (await canAccessExecutable(candidate)) return candidate;
598
+ }
599
+ }
600
+ return undefined;
601
+ }
602
+
603
+ function hasPathSeparator(path: string) {
604
+ return path.includes("/") || path.includes("\\");
605
+ }
606
+
607
+ function executableSearchPath() {
608
+ return (process.env.PATH ?? "").split(delimiter).filter((part) => part.length > 0);
609
+ }
610
+
611
+ function executableSearchNames(executable: string) {
612
+ if (process.platform !== "win32" || /\.[a-z0-9]+$/i.test(executable)) return [executable];
613
+ return [executable, `${executable}.exe`, `${executable}.cmd`, `${executable}.bat`];
614
+ }
615
+
616
+ async function canAccessExecutable(path: string) {
617
+ try {
618
+ await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
619
+ return true;
620
+ } catch {
621
+ return false;
622
+ }
623
+ }
624
+
625
+ function formatBrowserCandidate(candidate: BrowserCandidate) {
626
+ return `${candidate.label} (${candidate.resolvedExecutable})`;
627
+ }
628
+
629
+ function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition) {
630
+ return `${candidate.label} (${candidate.executable})`;
631
+ }
632
+
633
+ function noBrowserCandidateMessage(candidateDefinitions: BrowserCandidateDefinition[]) {
634
+ return [
635
+ "Cannot auto-launch Chrome DevTools because no Chromium-family browser executable was found.",
636
+ `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
637
+ endpointConfigHint(),
638
+ ].join("\n");
639
+ }
640
+
641
+ export function formatPageListItem(page: DevToolsPage) {
642
+ return `- ${page.id}: ${page.title || "(untitled)"} ${page.url}`;
643
+ }
644
+
645
+ function sleep(ms: number) {
646
+ return new Promise((resolve) => setTimeout(resolve, ms));
647
+ }
648
+
649
+ class DevToolsEndpointError extends Error {
650
+ readonly retryable: boolean;
651
+ readonly launchable: boolean;
652
+
653
+ constructor(message: string, options: { retryable?: boolean; launchable?: boolean } = {}) {
654
+ super(message);
655
+ this.name = "DevToolsEndpointError";
656
+ this.retryable = options.retryable ?? false;
657
+ this.launchable = options.launchable ?? false;
658
+ }
659
+ }