@narumitw/pi-chrome-devtools 0.49.3 → 0.50.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.
@@ -1,6 +1,7 @@
1
- import { type ChildProcess, spawn } from "node:child_process";
1
+ import { type ChildProcess, execFile, spawn } from "node:child_process";
2
2
  import { constants } from "node:fs";
3
3
  import { access, mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { createServer } from "node:net";
4
5
  import { tmpdir } from "node:os";
5
6
  import { delimiter, isAbsolute, join, resolve } from "node:path";
6
7
  import {
@@ -17,6 +18,45 @@ import {
17
18
  state,
18
19
  } from "./runtime.js";
19
20
 
21
+ interface BrowserSpawnOptions {
22
+ shell: false;
23
+ stdio: "ignore";
24
+ }
25
+
26
+ export interface BrowserManagerOperations {
27
+ access(path: string, mode: number): Promise<void>;
28
+ mkdtemp(prefix: string): Promise<string>;
29
+ readFile(path: string, encoding: "utf8"): Promise<string>;
30
+ rm(path: string, options: { recursive: true; force: true }): Promise<void>;
31
+ fetch(input: string, init?: RequestInit): Promise<Response>;
32
+ spawn(executable: string, args: string[], options: BrowserSpawnOptions): ChildProcess;
33
+ inspectBrowserVersion(executable: string, signal: AbortSignal): Promise<string>;
34
+ isPortAvailable(host: string, port: number): Promise<boolean>;
35
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
36
+ }
37
+
38
+ const DEFAULT_BROWSER_MANAGER_OPERATIONS: BrowserManagerOperations = {
39
+ access,
40
+ mkdtemp,
41
+ readFile,
42
+ rm: (path, options) => rm(path, options),
43
+ fetch: (input, init) => fetch(input, init),
44
+ spawn,
45
+ inspectBrowserVersion,
46
+ isPortAvailable,
47
+ sleep: abortableSleep,
48
+ };
49
+
50
+ let browserManagerOperations = DEFAULT_BROWSER_MANAGER_OPERATIONS;
51
+
52
+ export function setBrowserManagerOperationsForTests(overrides: Partial<BrowserManagerOperations>) {
53
+ const previous = browserManagerOperations;
54
+ browserManagerOperations = { ...DEFAULT_BROWSER_MANAGER_OPERATIONS, ...overrides };
55
+ return () => {
56
+ browserManagerOperations = previous;
57
+ };
58
+ }
59
+
20
60
  function formatError(error: unknown) {
21
61
  return error instanceof Error ? error.message : String(error);
22
62
  }
@@ -30,6 +70,11 @@ function normalizePathForComparison(value: string) {
30
70
  }
31
71
 
32
72
  export async function ensureDevToolsEndpoint(waitMs = DEFAULT_ENDPOINT_WAIT_MS) {
73
+ if (extensionsConfigured()) {
74
+ validateExtensionLaunchMode();
75
+ await ensureManagedBrowserLaunched(waitMs);
76
+ return;
77
+ }
33
78
  if (canAutoLaunchBrowser()) {
34
79
  try {
35
80
  await withEndpointRetry(() => fetchDevToolsJson<unknown>("/json/version"), waitMs);
@@ -51,25 +96,65 @@ export async function ensureDevToolsEndpoint(waitMs = DEFAULT_ENDPOINT_WAIT_MS)
51
96
  }
52
97
  }
53
98
 
99
+ function validateExtensionLaunchMode() {
100
+ if (!isLocalDevToolsHost(state.host)) {
101
+ throw new DevToolsEndpointError(
102
+ "Unpacked extensions require a local extension-owned managed browser; remote CDP endpoints cannot be modified.",
103
+ );
104
+ }
105
+ if (!state.autoLaunchEnabled) {
106
+ throw new DevToolsEndpointError(
107
+ "Auto-launch is required for unpacked extensions so Pi can create an isolated managed browser.",
108
+ );
109
+ }
110
+ if (!state.browserExecutable) {
111
+ throw new DevToolsEndpointError(
112
+ "Unpacked extensions require browser.executablePath for Chrome for Testing or Chromium in pi-chrome-devtools.json.",
113
+ );
114
+ }
115
+ }
116
+
54
117
  async function ensureManagedBrowserLaunched(waitMs: number) {
55
118
  if (state.launchPromise) return state.launchPromise;
56
119
  if (state.managedBrowser && !state.managedBrowser.exited && state.managedBrowser.ready) return;
120
+ const generation = state.sessionGeneration;
121
+ const signal = state.sessionController.signal;
122
+ throwIfBrowserLaunchCancelled(generation, signal);
123
+
124
+ const launchPromise = prepareManagedBrowserLaunch(waitMs, generation, signal);
125
+ const wrappedPromise = launchPromise.finally(() => {
126
+ if (state.launchPromise === wrappedPromise) state.launchPromise = undefined;
127
+ });
128
+ state.launchPromise = wrappedPromise;
129
+ return wrappedPromise;
130
+ }
131
+
132
+ async function prepareManagedBrowserLaunch(
133
+ waitMs: number,
134
+ generation: number,
135
+ signal: AbortSignal,
136
+ ) {
57
137
  if (state.managedBrowser) {
58
138
  await shutdownManagedBrowser(state.managedBrowser, { awaitLaunch: false });
139
+ throwIfBrowserLaunchCancelled(generation, signal);
59
140
  }
60
- throwIfBrowserLaunchCancelled();
61
-
62
- state.launchPromise = launchManagedBrowser(waitMs).finally(() => {
63
- state.launchPromise = undefined;
64
- });
65
- return state.launchPromise;
141
+ if (state.portConfigured) {
142
+ const available = await browserManagerOperations.isPortAvailable(state.host, state.port);
143
+ throwIfBrowserLaunchCancelled(generation, signal);
144
+ if (!available) {
145
+ throw new DevToolsEndpointError(
146
+ `Cannot launch the extension-owned browser because explicit port ${state.port} is already in use. Choose a free PI_CHROME_DEVTOOLS_PORT or remove the override for a dynamic port.`,
147
+ );
148
+ }
149
+ }
150
+ return launchManagedBrowser(waitMs, generation, signal);
66
151
  }
67
152
 
68
- async function launchManagedBrowser(waitMs: number) {
69
- throwIfBrowserLaunchCancelled();
153
+ async function launchManagedBrowser(waitMs: number, generation: number, signal: AbortSignal) {
154
+ throwIfBrowserLaunchCancelled(generation, signal);
70
155
  const candidateDefinitions = browserCandidateDefinitions();
71
- const candidates = await resolveBrowserCandidates(candidateDefinitions);
72
- throwIfBrowserLaunchCancelled();
156
+ const candidates = await resolveBrowserCandidates(candidateDefinitions, generation, signal);
157
+ throwIfBrowserLaunchCancelled(generation, signal);
73
158
  state.lastLaunchAttempt = {
74
159
  candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
75
160
  mode: state.portConfigured ? "explicit-port" : "dynamic-port",
@@ -81,9 +166,12 @@ async function launchManagedBrowser(waitMs: number) {
81
166
 
82
167
  let lastError: unknown;
83
168
  for (const candidate of candidates) {
84
- throwIfBrowserLaunchCancelled();
169
+ throwIfBrowserLaunchCancelled(generation, signal);
85
170
  try {
86
- await launchBrowserCandidate(candidate, waitMs);
171
+ if (extensionsConfigured())
172
+ await requireSupportedExtensionBrowser(candidate, generation, signal);
173
+ await launchBrowserCandidate(candidate, waitMs, generation, signal);
174
+ throwIfBrowserLaunchCancelled(generation, signal);
87
175
  state.lastLaunchAttempt = {
88
176
  ...state.lastLaunchAttempt,
89
177
  selectedCandidate: formatBrowserCandidate(candidate),
@@ -91,6 +179,15 @@ async function launchManagedBrowser(waitMs: number) {
91
179
  };
92
180
  return;
93
181
  } catch (error) {
182
+ try {
183
+ throwIfBrowserLaunchCancelled(generation, signal);
184
+ } catch (cancellation) {
185
+ state.lastLaunchAttempt = {
186
+ ...state.lastLaunchAttempt,
187
+ lastError: formatError(cancellation),
188
+ };
189
+ throw cancellation;
190
+ }
94
191
  lastError = error;
95
192
  state.lastLaunchAttempt = {
96
193
  ...state.lastLaunchAttempt,
@@ -99,6 +196,7 @@ async function launchManagedBrowser(waitMs: number) {
99
196
  }
100
197
  }
101
198
 
199
+ if (lastError instanceof DevToolsEndpointError && extensionsConfigured()) throw lastError;
102
200
  throw new DevToolsEndpointError(
103
201
  [
104
202
  "Unable to auto-launch a Chromium-family browser for Chrome DevTools.",
@@ -112,26 +210,72 @@ async function launchManagedBrowser(waitMs: number) {
112
210
  );
113
211
  }
114
212
 
115
- async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: number) {
116
- throwIfBrowserLaunchCancelled();
117
- const userDataDir = await mkdtemp(join(tmpdir(), MANAGED_BROWSER_PROFILE_PREFIX));
213
+ async function requireSupportedExtensionBrowser(
214
+ candidate: BrowserCandidate,
215
+ generation: number,
216
+ signal: AbortSignal,
217
+ ) {
218
+ let version: string;
219
+ try {
220
+ version = (
221
+ await browserManagerOperations.inspectBrowserVersion(candidate.resolvedExecutable, signal)
222
+ ).trim();
223
+ } catch (error) {
224
+ throwIfBrowserLaunchCancelled(generation, signal);
225
+ throw new DevToolsEndpointError(
226
+ `Unable to identify the configured extension browser ${candidate.resolvedExecutable}: ${formatError(error)}. Configure Chrome for Testing or Chromium.`,
227
+ );
228
+ }
229
+ throwIfBrowserLaunchCancelled(generation, signal);
230
+ const classification = classifyExtensionBrowserVersion(version);
231
+ if (!classification.supported) {
232
+ throw new DevToolsEndpointError(
233
+ `Unsupported browser for unpacked extensions: ${version || "unknown product"}. Configure Chrome for Testing or Chromium; branded Chrome may silently ignore extension startup flags.`,
234
+ );
235
+ }
236
+ }
237
+
238
+ export function classifyExtensionBrowserVersion(version: string): {
239
+ supported: boolean;
240
+ product: string;
241
+ } {
242
+ if (/^(?:Google )?Chrome for Testing\b/i.test(version)) {
243
+ return { supported: true, product: "Chrome for Testing" };
244
+ }
245
+ if (/^Chromium\b/i.test(version)) return { supported: true, product: "Chromium" };
246
+ const product = version.trim().replace(/\s+\d+(?:\.\d+)*.*$/, "") || "Unknown";
247
+ return { supported: false, product };
248
+ }
249
+
250
+ async function launchBrowserCandidate(
251
+ candidate: BrowserCandidate,
252
+ waitMs: number,
253
+ generation: number,
254
+ signal: AbortSignal,
255
+ ) {
256
+ throwIfBrowserLaunchCancelled(generation, signal);
257
+ const userDataDir = await browserManagerOperations.mkdtemp(
258
+ join(tmpdir(), MANAGED_BROWSER_PROFILE_PREFIX),
259
+ );
118
260
  let managedBrowser: ManagedBrowser | undefined;
119
261
  try {
262
+ throwIfBrowserLaunchCancelled(generation, signal);
120
263
  const portArgument = state.portConfigured ? String(state.port) : "0";
121
- const args = [
122
- `--remote-debugging-port=${portArgument}`,
123
- `--user-data-dir=${userDataDir}`,
124
- "--no-first-run",
125
- "--no-default-browser-check",
126
- "about:blank",
127
- ];
128
- throwIfBrowserLaunchCancelled();
129
- const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
264
+ const args = buildManagedBrowserLaunchArguments(
265
+ userDataDir,
266
+ portArgument,
267
+ state.extensionPaths,
268
+ );
269
+ const child = browserManagerOperations.spawn(candidate.resolvedExecutable, args, {
270
+ shell: false,
271
+ stdio: "ignore",
272
+ });
130
273
  const launchedBrowser: ManagedBrowser = {
131
274
  process: child,
132
275
  userDataDir,
133
276
  exited: false,
134
277
  ready: false,
278
+ ownerGeneration: generation,
135
279
  };
136
280
  managedBrowser = launchedBrowser;
137
281
  state.managedBrowser = launchedBrowser;
@@ -145,21 +289,56 @@ async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: numbe
145
289
  });
146
290
 
147
291
  await waitForBrowserSpawn(child);
292
+ throwIfBrowserLaunchCancelled(generation, signal);
148
293
  if (state.portConfigured) {
149
294
  launchedBrowser.port = state.port;
150
295
  } else {
151
- launchedBrowser.port = await readManagedBrowserPort(userDataDir, launchedBrowser, waitMs);
296
+ launchedBrowser.port = await readManagedBrowserPort(
297
+ userDataDir,
298
+ launchedBrowser,
299
+ waitMs,
300
+ generation,
301
+ signal,
302
+ );
303
+ throwIfBrowserLaunchCancelled(generation, signal);
152
304
  state.port = launchedBrowser.port;
153
305
  }
154
- await waitForDevToolsEndpoint(waitMs, launchedBrowser);
306
+ await waitForDevToolsEndpoint(waitMs, launchedBrowser, generation, signal);
307
+ throwIfBrowserLaunchCancelled(generation, signal);
155
308
  launchedBrowser.ready = true;
156
309
  } catch (error) {
157
310
  if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false });
158
- else await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
311
+ else {
312
+ await browserManagerOperations
313
+ .rm(userDataDir, { recursive: true, force: true })
314
+ .catch(() => undefined);
315
+ }
159
316
  throw error;
160
317
  }
161
318
  }
162
319
 
320
+ export function buildManagedBrowserLaunchArguments(
321
+ userDataDir: string,
322
+ portArgument: string,
323
+ extensionPaths: readonly string[],
324
+ ) {
325
+ const extensionList = extensionPaths.join(",");
326
+ return [
327
+ `--remote-debugging-port=${portArgument}`,
328
+ `--user-data-dir=${userDataDir}`,
329
+ ...(extensionPaths.length > 0
330
+ ? [`--disable-extensions-except=${extensionList}`, `--load-extension=${extensionList}`]
331
+ : []),
332
+ // Chrome 138+ de-elevates when launched from an elevated process on Windows: the
333
+ // spawned process relaunches a de-elevated child and exits immediately, which the
334
+ // exit watchdog misreads as a failed launch.
335
+ ...(process.platform === "win32" ? ["--do-not-de-elevate"] : []),
336
+ "--no-first-run",
337
+ "--no-default-browser-check",
338
+ "about:blank",
339
+ ];
340
+ }
341
+
163
342
  function waitForBrowserSpawn(child: ChildProcess) {
164
343
  return new Promise<void>((resolve, reject) => {
165
344
  let settled = false;
@@ -181,20 +360,24 @@ async function readManagedBrowserPort(
181
360
  userDataDir: string,
182
361
  managedBrowser: ManagedBrowser,
183
362
  waitMs: number,
363
+ generation: number,
364
+ signal: AbortSignal,
184
365
  ) {
185
366
  const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
186
367
  const deadline = Date.now() + waitMs;
187
368
  while (true) {
188
369
  throwIfManagedBrowserExited(managedBrowser);
189
- const text = await readFile(activePortFile, "utf8").catch((error: unknown) => {
190
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
191
- throw error;
192
- });
370
+ const text = await browserManagerOperations
371
+ .readFile(activePortFile, "utf8")
372
+ .catch((error: unknown) => {
373
+ if (isNodeError(error) && error.code === "ENOENT") return undefined;
374
+ throw error;
375
+ });
376
+ throwIfBrowserLaunchCancelled(generation, signal);
193
377
  const portText = text?.split(/\r?\n/, 1)[0]?.trim();
194
378
  const port = Number(portText);
195
379
  if (Number.isInteger(port) && port > 0) return port;
196
380
 
197
- throwIfBrowserLaunchCancelled();
198
381
  const remainingMs = deadline - Date.now();
199
382
  if (remainingMs <= 0) {
200
383
  throw new DevToolsEndpointError(
@@ -205,22 +388,28 @@ async function readManagedBrowserPort(
205
388
  ].join("\n"),
206
389
  );
207
390
  }
208
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
391
+ await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs), signal);
209
392
  }
210
393
  }
211
394
 
212
- async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBrowser) {
395
+ async function waitForDevToolsEndpoint(
396
+ waitMs: number,
397
+ managedBrowser: ManagedBrowser,
398
+ generation: number,
399
+ signal: AbortSignal,
400
+ ) {
213
401
  const deadline = Date.now() + waitMs;
214
402
  while (true) {
215
403
  throwIfManagedBrowserExited(managedBrowser);
216
404
  try {
217
- await fetchDevToolsJson<unknown>("/json/version");
405
+ await fetchDevToolsJson<unknown>("/json/version", { signal });
406
+ throwIfBrowserLaunchCancelled(generation, signal);
218
407
  return;
219
408
  } catch (error) {
220
409
  if (!isRetryableEndpointError(error)) throw error;
221
410
  }
222
411
 
223
- throwIfBrowserLaunchCancelled();
412
+ throwIfBrowserLaunchCancelled(generation, signal);
224
413
  const remainingMs = deadline - Date.now();
225
414
  if (remainingMs <= 0) {
226
415
  throw new DevToolsEndpointError(
@@ -230,7 +419,7 @@ async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBr
230
419
  ].join("\n"),
231
420
  );
232
421
  }
233
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
422
+ await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs), signal);
234
423
  }
235
424
  }
236
425
 
@@ -239,9 +428,15 @@ function throwIfManagedBrowserExited(managedBrowser: ManagedBrowser) {
239
428
  throw new DevToolsEndpointError("Auto-launched browser exited before DevTools became available.");
240
429
  }
241
430
 
242
- function throwIfBrowserLaunchCancelled() {
243
- if (!state.shuttingDown) return;
244
- throw new DevToolsEndpointError("Chrome DevTools browser launch cancelled during shutdown.");
431
+ function throwIfBrowserLaunchCancelled(generation: number, signal: AbortSignal) {
432
+ if (!state.shuttingDown && generation === state.sessionGeneration && !signal.aborted) {
433
+ return;
434
+ }
435
+ throw new DevToolsEndpointError(
436
+ generation === state.sessionGeneration
437
+ ? "Chrome DevTools browser launch cancelled during shutdown."
438
+ : "Chrome DevTools browser launch cancelled because the session was replaced.",
439
+ );
245
440
  }
246
441
 
247
442
  export async function shutdownManagedBrowser(
@@ -254,17 +449,30 @@ export async function shutdownManagedBrowser(
254
449
  managedBrowser = managedBrowser ?? state.managedBrowser;
255
450
  }
256
451
  if (!managedBrowser) return;
257
- if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
452
+ if (managedBrowser.cleanupPromise) return managedBrowser.cleanupPromise;
453
+
454
+ const cleanup = cleanupManagedBrowser(managedBrowser);
455
+ managedBrowser.cleanupPromise = cleanup;
456
+ return cleanup;
457
+ }
258
458
 
459
+ async function cleanupManagedBrowser(managedBrowser: ManagedBrowser) {
460
+ if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
259
461
  if (!managedBrowser.exited) {
260
462
  killManagedBrowserProcess(managedBrowser);
261
- await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(() => {
463
+ await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(async () => {
262
464
  killManagedBrowserProcess(managedBrowser, "SIGKILL");
465
+ await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(
466
+ () => undefined,
467
+ );
263
468
  });
264
469
  }
265
- await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
266
- if (!state.portConfigured && managedBrowser.port === state.port)
470
+ await browserManagerOperations
471
+ .rm(managedBrowser.userDataDir, { recursive: true, force: true })
472
+ .catch(() => undefined);
473
+ if (!state.portConfigured && managedBrowser.port === state.port) {
267
474
  state.port = state.configuredPort;
475
+ }
268
476
  }
269
477
 
270
478
  function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
@@ -301,9 +509,10 @@ export async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
301
509
  const url = `${devToolsEndpoint()}${path}`;
302
510
  let response: Response;
303
511
  try {
304
- response = await fetch(url, {
512
+ const timeoutSignal = AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS);
513
+ response = await browserManagerOperations.fetch(url, {
305
514
  ...init,
306
- signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
515
+ signal: init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal,
307
516
  });
308
517
  } catch (error) {
309
518
  throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
@@ -347,7 +556,7 @@ export async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs:
347
556
  const remainingMs = deadline - Date.now();
348
557
  if (remainingMs <= 0) throw error;
349
558
 
350
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
559
+ await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
351
560
  }
352
561
  }
353
562
  }
@@ -373,6 +582,10 @@ function canAutoLaunchBrowser() {
373
582
  return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
374
583
  }
375
584
 
585
+ function extensionsConfigured() {
586
+ return state.extensionPaths.length > 0;
587
+ }
588
+
376
589
  function endpointConnectionErrorMessage(error: unknown) {
377
590
  const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
378
591
  return [
@@ -401,7 +614,29 @@ export function endpointSourceLabel() {
401
614
  return `${hostSource}; ${portSource}`;
402
615
  }
403
616
 
617
+ export type BrowserLifecycleState = "starting" | "running" | "exited" | "failed" | "unobserved";
618
+
619
+ export function browserLifecycleState(): BrowserLifecycleState {
620
+ if (state.launchPromise) return "starting";
621
+ if (state.managedBrowser?.exited) return "exited";
622
+ if (state.managedBrowser?.ready) return "running";
623
+ if (state.lastLaunchAttempt?.lastError) return "failed";
624
+ return "unobserved";
625
+ }
626
+
404
627
  export function launchModeLabel() {
628
+ if (extensionsConfigured()) {
629
+ if (!isLocalDevToolsHost(state.host)) return "invalid; extensions require a local endpoint";
630
+ if (!state.autoLaunchEnabled) return "invalid; extensions require auto-launch";
631
+ if (state.managedBrowser && !state.managedBrowser.exited) {
632
+ return state.portConfigured
633
+ ? "extension-owned browser on explicit port"
634
+ : "extension-owned browser on dynamic port";
635
+ }
636
+ return state.portConfigured
637
+ ? "force extension-owned launch on explicit port"
638
+ : "force extension-owned launch on dynamic port";
639
+ }
405
640
  if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
406
641
  if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
407
642
  if (state.managedBrowser && !state.managedBrowser.exited) {
@@ -433,6 +668,9 @@ export function launchAttemptLines() {
433
668
  }
434
669
 
435
670
  export function launchHint() {
671
+ if (extensionsConfigured()) {
672
+ return "Configured unpacked extensions force an isolated extension-owned launch; existing CDP browsers are never reused, modified, or closed.";
673
+ }
436
674
  if (!isLocalDevToolsHost(state.host)) {
437
675
  return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
438
676
  }
@@ -444,6 +682,9 @@ export function launchHint() {
444
682
  }
445
683
 
446
684
  export function browserCandidateHint() {
685
+ if (extensionsConfigured()) {
686
+ return "Extension browser requirement: configure Chrome for Testing or Chromium with browser.executablePath; branded Chrome is rejected because it may ignore unpacked-extension flags.";
687
+ }
447
688
  return `Browser candidates: ${browserCandidateDefinitions()
448
689
  .map((candidate) => candidate.label)
449
690
  .join(", ")}`;
@@ -487,7 +728,14 @@ function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
487
728
  function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
488
729
  if (!state.browserExecutable) return [];
489
730
  return [
490
- { label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" },
731
+ {
732
+ label:
733
+ state.browserExecutableSource === "environment"
734
+ ? "PI_CHROME_DEVTOOLS_BROWSER"
735
+ : "browser.executablePath",
736
+ executable: state.browserExecutable,
737
+ source: "env",
738
+ },
491
739
  ];
492
740
  }
493
741
 
@@ -578,10 +826,15 @@ function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
578
826
  });
579
827
  }
580
828
 
581
- async function resolveBrowserCandidates(definitions: BrowserCandidateDefinition[]) {
829
+ async function resolveBrowserCandidates(
830
+ definitions: BrowserCandidateDefinition[],
831
+ generation: number,
832
+ signal: AbortSignal,
833
+ ) {
582
834
  const candidates: BrowserCandidate[] = [];
583
835
  for (const definition of definitions) {
584
836
  const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
837
+ throwIfBrowserLaunchCancelled(generation, signal);
585
838
  if (!resolvedExecutable) continue;
586
839
  candidates.push({ ...definition, resolvedExecutable });
587
840
  }
@@ -628,7 +881,10 @@ function executableSearchNames(executable: string) {
628
881
 
629
882
  async function canAccessExecutable(path: string) {
630
883
  try {
631
- await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
884
+ await browserManagerOperations.access(
885
+ path,
886
+ process.platform === "win32" ? constants.F_OK : constants.X_OK,
887
+ );
632
888
  return true;
633
889
  } catch {
634
890
  return false;
@@ -644,6 +900,13 @@ function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition)
644
900
  }
645
901
 
646
902
  function noBrowserCandidateMessage(candidateDefinitions: BrowserCandidateDefinition[]) {
903
+ if (extensionsConfigured()) {
904
+ return [
905
+ "Cannot launch unpacked extensions because the configured browser executable was not found or is not executable.",
906
+ `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
907
+ "Set browser.executablePath to an executable Chrome for Testing or Chromium binary.",
908
+ ].join("\n");
909
+ }
647
910
  return [
648
911
  "Cannot auto-launch Chrome DevTools because no Chromium-family browser executable was found.",
649
912
  `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
@@ -655,8 +918,58 @@ export function formatPageListItem(page: DevToolsPage) {
655
918
  return `- ${page.id}: ${page.title || "(untitled)"} ${page.url}`;
656
919
  }
657
920
 
658
- function sleep(ms: number) {
659
- return new Promise((resolve) => setTimeout(resolve, ms));
921
+ function abortableSleep(ms: number, signal?: AbortSignal) {
922
+ return new Promise<void>((resolveSleep, reject) => {
923
+ if (signal?.aborted) {
924
+ reject(signal.reason);
925
+ return;
926
+ }
927
+ const timeout = setTimeout(settleResolve, ms);
928
+ const onAbort = () => settleReject(signal?.reason);
929
+ function cleanup() {
930
+ clearTimeout(timeout);
931
+ signal?.removeEventListener("abort", onAbort);
932
+ }
933
+ function settleResolve() {
934
+ cleanup();
935
+ resolveSleep();
936
+ }
937
+ function settleReject(reason: unknown) {
938
+ cleanup();
939
+ reject(reason);
940
+ }
941
+ signal?.addEventListener("abort", onAbort, { once: true });
942
+ });
943
+ }
944
+
945
+ function inspectBrowserVersion(executable: string, signal: AbortSignal) {
946
+ return new Promise<string>((resolveVersion, reject) => {
947
+ execFile(executable, ["--version"], { signal, timeout: 5_000 }, (error, stdout, stderr) => {
948
+ if (error) {
949
+ reject(error);
950
+ return;
951
+ }
952
+ resolveVersion(`${stdout}${stderr}`.trim());
953
+ });
954
+ });
955
+ }
956
+
957
+ function isPortAvailable(host: string, port: number) {
958
+ return new Promise<boolean>((resolveAvailability, reject) => {
959
+ const server = createServer();
960
+ const normalizedHost = host.replace(/^\[(.*)]$/, "$1");
961
+ server.unref();
962
+ server.once("error", (error: NodeJS.ErrnoException) => {
963
+ if (error.code === "EADDRINUSE") resolveAvailability(false);
964
+ else reject(error);
965
+ });
966
+ server.listen(port, normalizedHost, () => {
967
+ server.close((error) => {
968
+ if (error) reject(error);
969
+ else resolveAvailability(true);
970
+ });
971
+ });
972
+ });
660
973
  }
661
974
 
662
975
  class DevToolsEndpointError extends Error {