@narumitw/pi-chrome-devtools 0.49.4 → 0.50.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.
@@ -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,30 +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
- // Chrome 138+ de-elevates when launched from an elevated process on Windows: the
125
- // spawned process relaunches a de-elevated child and exits immediately, which the
126
- // exit watchdog misreads as a failed launch.
127
- ...(process.platform === "win32" ? ["--do-not-de-elevate"] : []),
128
- "--no-first-run",
129
- "--no-default-browser-check",
130
- "about:blank",
131
- ];
132
- throwIfBrowserLaunchCancelled();
133
- 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
+ });
134
273
  const launchedBrowser: ManagedBrowser = {
135
274
  process: child,
136
275
  userDataDir,
137
276
  exited: false,
138
277
  ready: false,
278
+ ownerGeneration: generation,
139
279
  };
140
280
  managedBrowser = launchedBrowser;
141
281
  state.managedBrowser = launchedBrowser;
@@ -149,21 +289,56 @@ async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: numbe
149
289
  });
150
290
 
151
291
  await waitForBrowserSpawn(child);
292
+ throwIfBrowserLaunchCancelled(generation, signal);
152
293
  if (state.portConfigured) {
153
294
  launchedBrowser.port = state.port;
154
295
  } else {
155
- 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);
156
304
  state.port = launchedBrowser.port;
157
305
  }
158
- await waitForDevToolsEndpoint(waitMs, launchedBrowser);
306
+ await waitForDevToolsEndpoint(waitMs, launchedBrowser, generation, signal);
307
+ throwIfBrowserLaunchCancelled(generation, signal);
159
308
  launchedBrowser.ready = true;
160
309
  } catch (error) {
161
310
  if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false });
162
- 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
+ }
163
316
  throw error;
164
317
  }
165
318
  }
166
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
+
167
342
  function waitForBrowserSpawn(child: ChildProcess) {
168
343
  return new Promise<void>((resolve, reject) => {
169
344
  let settled = false;
@@ -185,20 +360,24 @@ async function readManagedBrowserPort(
185
360
  userDataDir: string,
186
361
  managedBrowser: ManagedBrowser,
187
362
  waitMs: number,
363
+ generation: number,
364
+ signal: AbortSignal,
188
365
  ) {
189
366
  const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
190
367
  const deadline = Date.now() + waitMs;
191
368
  while (true) {
192
369
  throwIfManagedBrowserExited(managedBrowser);
193
- const text = await readFile(activePortFile, "utf8").catch((error: unknown) => {
194
- if (isNodeError(error) && error.code === "ENOENT") return undefined;
195
- throw error;
196
- });
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);
197
377
  const portText = text?.split(/\r?\n/, 1)[0]?.trim();
198
378
  const port = Number(portText);
199
379
  if (Number.isInteger(port) && port > 0) return port;
200
380
 
201
- throwIfBrowserLaunchCancelled();
202
381
  const remainingMs = deadline - Date.now();
203
382
  if (remainingMs <= 0) {
204
383
  throw new DevToolsEndpointError(
@@ -209,22 +388,28 @@ async function readManagedBrowserPort(
209
388
  ].join("\n"),
210
389
  );
211
390
  }
212
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
391
+ await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs), signal);
213
392
  }
214
393
  }
215
394
 
216
- async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBrowser) {
395
+ async function waitForDevToolsEndpoint(
396
+ waitMs: number,
397
+ managedBrowser: ManagedBrowser,
398
+ generation: number,
399
+ signal: AbortSignal,
400
+ ) {
217
401
  const deadline = Date.now() + waitMs;
218
402
  while (true) {
219
403
  throwIfManagedBrowserExited(managedBrowser);
220
404
  try {
221
- await fetchDevToolsJson<unknown>("/json/version");
405
+ await fetchDevToolsJson<unknown>("/json/version", { signal });
406
+ throwIfBrowserLaunchCancelled(generation, signal);
222
407
  return;
223
408
  } catch (error) {
224
409
  if (!isRetryableEndpointError(error)) throw error;
225
410
  }
226
411
 
227
- throwIfBrowserLaunchCancelled();
412
+ throwIfBrowserLaunchCancelled(generation, signal);
228
413
  const remainingMs = deadline - Date.now();
229
414
  if (remainingMs <= 0) {
230
415
  throw new DevToolsEndpointError(
@@ -234,7 +419,7 @@ async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBr
234
419
  ].join("\n"),
235
420
  );
236
421
  }
237
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
422
+ await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs), signal);
238
423
  }
239
424
  }
240
425
 
@@ -243,9 +428,15 @@ function throwIfManagedBrowserExited(managedBrowser: ManagedBrowser) {
243
428
  throw new DevToolsEndpointError("Auto-launched browser exited before DevTools became available.");
244
429
  }
245
430
 
246
- function throwIfBrowserLaunchCancelled() {
247
- if (!state.shuttingDown) return;
248
- 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
+ );
249
440
  }
250
441
 
251
442
  export async function shutdownManagedBrowser(
@@ -258,17 +449,30 @@ export async function shutdownManagedBrowser(
258
449
  managedBrowser = managedBrowser ?? state.managedBrowser;
259
450
  }
260
451
  if (!managedBrowser) return;
261
- 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
+ }
262
458
 
459
+ async function cleanupManagedBrowser(managedBrowser: ManagedBrowser) {
460
+ if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
263
461
  if (!managedBrowser.exited) {
264
462
  killManagedBrowserProcess(managedBrowser);
265
- await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(() => {
463
+ await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(async () => {
266
464
  killManagedBrowserProcess(managedBrowser, "SIGKILL");
465
+ await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(
466
+ () => undefined,
467
+ );
267
468
  });
268
469
  }
269
- await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
270
- 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) {
271
474
  state.port = state.configuredPort;
475
+ }
272
476
  }
273
477
 
274
478
  function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
@@ -305,9 +509,10 @@ export async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
305
509
  const url = `${devToolsEndpoint()}${path}`;
306
510
  let response: Response;
307
511
  try {
308
- response = await fetch(url, {
512
+ const timeoutSignal = AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS);
513
+ response = await browserManagerOperations.fetch(url, {
309
514
  ...init,
310
- signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
515
+ signal: init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal,
311
516
  });
312
517
  } catch (error) {
313
518
  throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
@@ -351,7 +556,7 @@ export async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs:
351
556
  const remainingMs = deadline - Date.now();
352
557
  if (remainingMs <= 0) throw error;
353
558
 
354
- await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
559
+ await browserManagerOperations.sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
355
560
  }
356
561
  }
357
562
  }
@@ -377,6 +582,10 @@ function canAutoLaunchBrowser() {
377
582
  return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
378
583
  }
379
584
 
585
+ function extensionsConfigured() {
586
+ return state.extensionPaths.length > 0;
587
+ }
588
+
380
589
  function endpointConnectionErrorMessage(error: unknown) {
381
590
  const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
382
591
  return [
@@ -405,7 +614,29 @@ export function endpointSourceLabel() {
405
614
  return `${hostSource}; ${portSource}`;
406
615
  }
407
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
+
408
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
+ }
409
640
  if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
410
641
  if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
411
642
  if (state.managedBrowser && !state.managedBrowser.exited) {
@@ -437,6 +668,9 @@ export function launchAttemptLines() {
437
668
  }
438
669
 
439
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
+ }
440
674
  if (!isLocalDevToolsHost(state.host)) {
441
675
  return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
442
676
  }
@@ -448,6 +682,9 @@ export function launchHint() {
448
682
  }
449
683
 
450
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
+ }
451
688
  return `Browser candidates: ${browserCandidateDefinitions()
452
689
  .map((candidate) => candidate.label)
453
690
  .join(", ")}`;
@@ -491,7 +728,14 @@ function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
491
728
  function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
492
729
  if (!state.browserExecutable) return [];
493
730
  return [
494
- { 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
+ },
495
739
  ];
496
740
  }
497
741
 
@@ -582,10 +826,15 @@ function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
582
826
  });
583
827
  }
584
828
 
585
- async function resolveBrowserCandidates(definitions: BrowserCandidateDefinition[]) {
829
+ async function resolveBrowserCandidates(
830
+ definitions: BrowserCandidateDefinition[],
831
+ generation: number,
832
+ signal: AbortSignal,
833
+ ) {
586
834
  const candidates: BrowserCandidate[] = [];
587
835
  for (const definition of definitions) {
588
836
  const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
837
+ throwIfBrowserLaunchCancelled(generation, signal);
589
838
  if (!resolvedExecutable) continue;
590
839
  candidates.push({ ...definition, resolvedExecutable });
591
840
  }
@@ -632,7 +881,10 @@ function executableSearchNames(executable: string) {
632
881
 
633
882
  async function canAccessExecutable(path: string) {
634
883
  try {
635
- 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
+ );
636
888
  return true;
637
889
  } catch {
638
890
  return false;
@@ -648,6 +900,13 @@ function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition)
648
900
  }
649
901
 
650
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
+ }
651
910
  return [
652
911
  "Cannot auto-launch Chrome DevTools because no Chromium-family browser executable was found.",
653
912
  `Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
@@ -659,8 +918,58 @@ export function formatPageListItem(page: DevToolsPage) {
659
918
  return `- ${page.id}: ${page.title || "(untitled)"} ${page.url}`;
660
919
  }
661
920
 
662
- function sleep(ms: number) {
663
- 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
+ });
664
973
  }
665
974
 
666
975
  class DevToolsEndpointError extends Error {