@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.
- package/README.md +117 -40
- package/package.json +7 -4
- package/src/browser-manager.ts +366 -53
- 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,26 +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
|
-
|
|
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(
|
|
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
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
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)
|
|
244
|
-
|
|
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 (
|
|
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
|
|
266
|
-
|
|
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
|
-
|
|
512
|
+
const timeoutSignal = AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS);
|
|
513
|
+
response = await browserManagerOperations.fetch(url, {
|
|
305
514
|
...init,
|
|
306
|
-
signal: AbortSignal.
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
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
|
|
659
|
-
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
|
+
});
|
|
660
973
|
}
|
|
661
974
|
|
|
662
975
|
class DevToolsEndpointError extends Error {
|