@narumitw/pi-chrome-devtools 0.49.4 → 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.
- package/README.md +115 -38
- package/package.json +1 -1
- package/src/browser-manager.ts +366 -57
- package/src/chrome-devtools.ts +46 -81
- package/src/menu.ts +483 -0
- package/src/runtime.ts +27 -0
- package/src/settings.ts +337 -82
- package/src/tool-selector.ts +145 -130
package/src/browser-manager.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
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(
|
|
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
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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(
|
|
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)
|
|
248
|
-
|
|
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 (
|
|
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
|
|
270
|
-
|
|
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
|
-
|
|
512
|
+
const timeoutSignal = AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS);
|
|
513
|
+
response = await browserManagerOperations.fetch(url, {
|
|
309
514
|
...init,
|
|
310
|
-
signal: AbortSignal.
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
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
|
|
663
|
-
return new Promise((
|
|
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 {
|