@narumitw/pi-chrome-devtools 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -13
- package/package.json +1 -1
- package/src/chrome-devtools.ts +911 -34
package/README.md
CHANGED
|
@@ -15,9 +15,13 @@ This package is inspired by [`chrome-devtools-mcp`](https://github.com/ChromeDev
|
|
|
15
15
|
- Navigates Chrome to a target URL, creating an inspectable page when none exists.
|
|
16
16
|
- Recovers from stale active page selections by falling back to an available page.
|
|
17
17
|
- Evaluates JavaScript in the selected page.
|
|
18
|
-
- Captures PNG screenshots, including optional full-page screenshots.
|
|
18
|
+
- Captures PNG screenshots, including optional full-page screenshots, and saves them to disk.
|
|
19
19
|
- Renders compact tool results that expand/collapse with Pi's default output toggle (`Ctrl+O`).
|
|
20
|
-
-
|
|
20
|
+
- Reuses an existing Chrome DevTools Protocol endpoint when one is already available.
|
|
21
|
+
- Lazily auto-launches a Chromium-family browser for missing local endpoints, with Chrome,
|
|
22
|
+
Chromium, Brave, and Edge fallbacks.
|
|
23
|
+
- Uses a dynamic managed DevTools port by default to avoid port conflicts, while preserving
|
|
24
|
+
explicit endpoint overrides.
|
|
21
25
|
- Retries briefly while Chrome is starting and reports actionable endpoint errors.
|
|
22
26
|
- Shows statusline activity only while Chrome DevTools tools are running.
|
|
23
27
|
- Provides a `/chrome-devtools` menu with quick-start help and tool controls.
|
|
@@ -42,9 +46,37 @@ Try this package locally from the repository root:
|
|
|
42
46
|
pi -e ./extensions/pi-chrome-devtools
|
|
43
47
|
```
|
|
44
48
|
|
|
45
|
-
## 🚀
|
|
49
|
+
## 🚀 Browser startup
|
|
46
50
|
|
|
47
|
-
The extension
|
|
51
|
+
The extension first tries the configured endpoint, defaulting to `127.0.0.1:9222`. If that
|
|
52
|
+
local endpoint is unavailable, it lazily launches an extension-owned Chromium-family browser with
|
|
53
|
+
an isolated temp profile and then retries the CDP request. Existing endpoints are reused and are
|
|
54
|
+
not terminated by the extension.
|
|
55
|
+
|
|
56
|
+
When `PI_CHROME_DEVTOOLS_PORT` is not set, auto-launch uses Chrome's dynamic DevTools port mode
|
|
57
|
+
(`--remote-debugging-port=0`) and reads `DevToolsActivePort` from the temp profile. This avoids
|
|
58
|
+
forcing every Pi session onto port `9222`. If you set `PI_CHROME_DEVTOOLS_PORT` to a valid port
|
|
59
|
+
(`1`-`65535`), the extension uses that explicit port for both attach and auto-launch. Empty or
|
|
60
|
+
invalid port values are ignored and fall back to the default attach-first behavior.
|
|
61
|
+
|
|
62
|
+
When `PI_CHROME_DEVTOOLS_BROWSER` is set, that executable is the only auto-launch candidate; a
|
|
63
|
+
missing or unusable forced browser reports an error instead of falling back. Without that override,
|
|
64
|
+
browser discovery checks platform-specific Chrome, Chromium, Brave, and Microsoft Edge candidates.
|
|
65
|
+
Disable auto-launch to keep the older manual flow:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 pi -e ./extensions/pi-chrome-devtools
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Force a browser executable or endpoint if needed:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
PI_CHROME_DEVTOOLS_BROWSER=/usr/bin/brave-browser pi -e ./extensions/pi-chrome-devtools
|
|
75
|
+
PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extensions/pi-chrome-devtools
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Manual launch still works and is required for remote endpoints, opt-out mode, or unsupported WSL
|
|
79
|
+
browser/profile path setups:
|
|
48
80
|
|
|
49
81
|
```bash
|
|
50
82
|
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/pi-chrome-devtools
|
|
@@ -58,11 +90,9 @@ On macOS:
|
|
|
58
90
|
--user-data-dir=/tmp/pi-chrome-devtools
|
|
59
91
|
```
|
|
60
92
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extensions/pi-chrome-devtools
|
|
65
|
-
```
|
|
93
|
+
On session shutdown, the extension terminates only browser processes it started itself and
|
|
94
|
+
best-effort removes their temp profiles. It never closes user-started browsers or remote
|
|
95
|
+
endpoints.
|
|
66
96
|
|
|
67
97
|
## 🛠️ Pi tools
|
|
68
98
|
|
|
@@ -70,7 +100,34 @@ PI_CHROME_DEVTOOLS_HOST=127.0.0.1 PI_CHROME_DEVTOOLS_PORT=9223 pi -e ./extension
|
|
|
70
100
|
- `chrome_devtools_select_page` — select the active page for later tool calls.
|
|
71
101
|
- `chrome_devtools_navigate` — navigate a page to a URL; if no page exists, create one first.
|
|
72
102
|
- `chrome_devtools_evaluate` — evaluate JavaScript in the selected page.
|
|
73
|
-
- `chrome_devtools_screenshot` — capture a PNG screenshot.
|
|
103
|
+
- `chrome_devtools_screenshot` — capture a PNG screenshot and save it as a PNG file.
|
|
104
|
+
|
|
105
|
+
### Screenshot files
|
|
106
|
+
|
|
107
|
+
`chrome_devtools_screenshot` always saves the captured PNG to disk. If `savePath` is omitted,
|
|
108
|
+
the extension writes a unique temp file such as:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
/tmp/pi-chrome-devtools-screenshot-<uuid>.png
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Pass `savePath` to choose the output path:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
chrome_devtools_screenshot({
|
|
118
|
+
fullPage: true,
|
|
119
|
+
savePath: "artifacts/homepage.png",
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Relative `savePath` values resolve from Pi's current working directory. A single leading `@`
|
|
124
|
+
is stripped to match Pi file-mention paths. Absolute paths are accepted only when they stay
|
|
125
|
+
inside the current working directory or the OS temp directory. Paths containing `..` segments,
|
|
126
|
+
NUL bytes, symlinked parent directories, directories as targets, final symbolic-link targets, or
|
|
127
|
+
other non-regular file targets are rejected. Existing regular files at the target path are
|
|
128
|
+
replaced. The tool result includes the resolved path, byte count, and an inline image block when
|
|
129
|
+
the active model/provider can consume images. If the model cannot inspect the inline image, ask it
|
|
130
|
+
to read the saved path, for example `read({ path: "artifacts/homepage.png" })`.
|
|
74
131
|
|
|
75
132
|
## 💬 Command
|
|
76
133
|
|
|
@@ -94,9 +151,10 @@ Direct subcommands are also available:
|
|
|
94
151
|
```
|
|
95
152
|
|
|
96
153
|
- `help` shows command usage.
|
|
97
|
-
- `quickstart` shows the configured CDP endpoint
|
|
98
|
-
|
|
99
|
-
|
|
154
|
+
- `quickstart` shows the configured CDP endpoint, endpoint source, auto-launch mode, browser
|
|
155
|
+
candidates, last launch attempt, and launch hints.
|
|
156
|
+
- `status` shows runtime tool state, persisted selection, settings file path, endpoint source,
|
|
157
|
+
launch mode, last launch attempt, and active non-Chrome tool count.
|
|
100
158
|
- `tools` opens a Plan-mode-style selector for choosing individual `chrome_devtools_*` tools.
|
|
101
159
|
- `toggle` is an alias for `tools`.
|
|
102
160
|
- `enable` enables all `chrome_devtools_*` tools for future turns.
|
package/package.json
CHANGED
package/src/chrome-devtools.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
7
|
import {
|
|
5
8
|
defineTool,
|
|
6
9
|
type AgentToolResult,
|
|
7
10
|
type ExtensionAPI,
|
|
8
11
|
type ExtensionCommandContext,
|
|
9
12
|
type ToolRenderResultOptions,
|
|
13
|
+
withFileMutationQueue,
|
|
10
14
|
} from "@mariozechner/pi-coding-agent";
|
|
11
15
|
import { Type } from "typebox";
|
|
12
16
|
|
|
@@ -16,6 +20,9 @@ const DEFAULT_TIMEOUT_MS = 10_000;
|
|
|
16
20
|
const DEFAULT_HTTP_TIMEOUT_MS = 1_000;
|
|
17
21
|
const DEFAULT_ENDPOINT_WAIT_MS = 5_000;
|
|
18
22
|
const DEFAULT_ENDPOINT_RETRY_MS = 250;
|
|
23
|
+
const MANAGED_BROWSER_PROFILE_PREFIX = "pi-chrome-devtools-profile-";
|
|
24
|
+
const DEVTOOLS_ACTIVE_PORT_FILE = "DevToolsActivePort";
|
|
25
|
+
const BROWSER_SHUTDOWN_WAIT_MS = 1_500;
|
|
19
26
|
const STATUS_KEY = "chrome-devtools";
|
|
20
27
|
const SETTINGS_FILE = join(
|
|
21
28
|
process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
|
|
@@ -61,6 +68,7 @@ type CommandAction =
|
|
|
61
68
|
| "disable";
|
|
62
69
|
type CommandContext = ExtensionCommandContext;
|
|
63
70
|
type ToolSelectorAction = "enableAll" | "disableAll" | "done";
|
|
71
|
+
type BrowserCandidateSource = "env" | "path" | "wellKnownPath";
|
|
64
72
|
type ToolSelectorRow =
|
|
65
73
|
| { kind: "tool"; toolName: ChromeDevToolsToolName }
|
|
66
74
|
| { kind: "action"; action: ToolSelectorAction; label: string };
|
|
@@ -90,7 +98,32 @@ interface DevToolsPage {
|
|
|
90
98
|
interface ChromeDevToolsState {
|
|
91
99
|
host: string;
|
|
92
100
|
port: number;
|
|
101
|
+
configuredPort: number;
|
|
102
|
+
hostConfigured: boolean;
|
|
103
|
+
portConfigured: boolean;
|
|
104
|
+
autoLaunchEnabled: boolean;
|
|
105
|
+
browserExecutable?: string;
|
|
93
106
|
activePageId?: string;
|
|
107
|
+
managedBrowser?: ManagedBrowser;
|
|
108
|
+
launchPromise?: Promise<void>;
|
|
109
|
+
lastLaunchAttempt?: BrowserLaunchAttempt;
|
|
110
|
+
shuttingDown: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ManagedBrowser {
|
|
114
|
+
process: ChildProcess;
|
|
115
|
+
userDataDir: string;
|
|
116
|
+
port?: number;
|
|
117
|
+
exited: boolean;
|
|
118
|
+
ready: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface BrowserLaunchAttempt {
|
|
122
|
+
candidateLabels: string[];
|
|
123
|
+
mode: "dynamic-port" | "explicit-port";
|
|
124
|
+
selectedCandidate?: string;
|
|
125
|
+
userDataDir?: string;
|
|
126
|
+
lastError?: string;
|
|
94
127
|
}
|
|
95
128
|
|
|
96
129
|
interface ChromeDevToolsSettings {
|
|
@@ -114,9 +147,41 @@ interface CdpResponse<T = unknown> {
|
|
|
114
147
|
};
|
|
115
148
|
}
|
|
116
149
|
|
|
150
|
+
interface ResolvedScreenshotPath {
|
|
151
|
+
path: string;
|
|
152
|
+
allowedRoots: string[];
|
|
153
|
+
isDefault: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface ScreenshotSaveResult {
|
|
157
|
+
savedPath: string;
|
|
158
|
+
bytes: number;
|
|
159
|
+
isDefaultPath: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface BrowserCandidateDefinition {
|
|
163
|
+
label: string;
|
|
164
|
+
executable: string;
|
|
165
|
+
source: BrowserCandidateSource;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface BrowserCandidate extends BrowserCandidateDefinition {
|
|
169
|
+
resolvedExecutable: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const configuredHost = process.env.PI_CHROME_DEVTOOLS_HOST ?? DEFAULT_HOST;
|
|
173
|
+
const configuredPortOverride = parseConfiguredPort(process.env.PI_CHROME_DEVTOOLS_PORT);
|
|
174
|
+
const configuredPort = configuredPortOverride ?? DEFAULT_PORT;
|
|
175
|
+
|
|
117
176
|
const state: ChromeDevToolsState = {
|
|
118
|
-
host:
|
|
119
|
-
port:
|
|
177
|
+
host: configuredHost,
|
|
178
|
+
port: configuredPort,
|
|
179
|
+
configuredPort,
|
|
180
|
+
hostConfigured: process.env.PI_CHROME_DEVTOOLS_HOST !== undefined,
|
|
181
|
+
portConfigured: configuredPortOverride !== undefined,
|
|
182
|
+
autoLaunchEnabled: process.env.PI_CHROME_DEVTOOLS_AUTO_LAUNCH !== "0",
|
|
183
|
+
browserExecutable: process.env.PI_CHROME_DEVTOOLS_BROWSER,
|
|
184
|
+
shuttingDown: false,
|
|
120
185
|
};
|
|
121
186
|
|
|
122
187
|
const listPagesTool = defineTool({
|
|
@@ -234,13 +299,20 @@ const screenshotTool = defineTool({
|
|
|
234
299
|
fullPage: Type.Optional(
|
|
235
300
|
Type.Boolean({ description: "Capture the full document, not just the viewport." }),
|
|
236
301
|
),
|
|
302
|
+
savePath: Type.Optional(
|
|
303
|
+
Type.String({
|
|
304
|
+
description:
|
|
305
|
+
"Screenshot is always saved as a PNG file. Optional output path; omitted defaults to a unique temp file. Relative paths resolve from the current working directory. A single leading @ is stripped to match Pi file-mention paths. Existing regular files are replaced.",
|
|
306
|
+
}),
|
|
307
|
+
),
|
|
237
308
|
}),
|
|
238
309
|
renderCall: renderToolCall("screenshot"),
|
|
239
310
|
renderResult: renderScreenshotResult,
|
|
240
|
-
async execute(_toolCallId, params,
|
|
311
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
241
312
|
return withStatus(ctx, "🌐 screenshot", async () => {
|
|
242
313
|
const page = await resolvePage(params.pageId);
|
|
243
314
|
const result = await withCdp(page, async (client) => {
|
|
315
|
+
throwIfAborted(signal);
|
|
244
316
|
await client.send("Page.enable");
|
|
245
317
|
|
|
246
318
|
if (!params.fullPage) {
|
|
@@ -251,6 +323,7 @@ const screenshotTool = defineTool({
|
|
|
251
323
|
contentSize: { x: number; y: number; width: number; height: number };
|
|
252
324
|
}>("Page.getLayoutMetrics");
|
|
253
325
|
|
|
326
|
+
throwIfAborted(signal);
|
|
254
327
|
return client.send<{ data: string }>("Page.captureScreenshot", {
|
|
255
328
|
captureBeyondViewport: true,
|
|
256
329
|
format: "png",
|
|
@@ -265,12 +338,21 @@ const screenshotTool = defineTool({
|
|
|
265
338
|
});
|
|
266
339
|
|
|
267
340
|
state.activePageId = page.id;
|
|
341
|
+
const savedScreenshot = await saveScreenshot(result.data, params.savePath, ctx.cwd, signal);
|
|
268
342
|
return {
|
|
269
343
|
content: [
|
|
270
|
-
{
|
|
344
|
+
{
|
|
345
|
+
type: "text",
|
|
346
|
+
text: formatScreenshotText(page, savedScreenshot),
|
|
347
|
+
},
|
|
271
348
|
{ type: "image", data: result.data, mimeType: "image/png" },
|
|
272
349
|
],
|
|
273
|
-
details: {
|
|
350
|
+
details: {
|
|
351
|
+
page: formatPage(page),
|
|
352
|
+
bytes: savedScreenshot.bytes,
|
|
353
|
+
savedPath: savedScreenshot.savedPath,
|
|
354
|
+
isDefaultPath: savedScreenshot.isDefaultPath,
|
|
355
|
+
},
|
|
274
356
|
};
|
|
275
357
|
});
|
|
276
358
|
},
|
|
@@ -292,6 +374,7 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
292
374
|
});
|
|
293
375
|
|
|
294
376
|
pi.on("session_start", async (_event, ctx) => {
|
|
377
|
+
state.shuttingDown = false;
|
|
295
378
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
296
379
|
const settings = await loadSettings();
|
|
297
380
|
if (settings.kind === "loaded") {
|
|
@@ -303,8 +386,9 @@ export default function chromeDevtools(pi: ExtensionAPI) {
|
|
|
303
386
|
}
|
|
304
387
|
});
|
|
305
388
|
|
|
306
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
389
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
307
390
|
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
391
|
+
await shutdownManagedBrowser(undefined, { cancelLaunch: true });
|
|
308
392
|
});
|
|
309
393
|
}
|
|
310
394
|
|
|
@@ -589,13 +673,20 @@ async function buildToolStatusMessage(pi: ExtensionAPI) {
|
|
|
589
673
|
`Settings file: ${SETTINGS_FILE}`,
|
|
590
674
|
`Other active tools preserved: ${summary.activeNonChromeToolCount}`,
|
|
591
675
|
`Endpoint: ${devToolsEndpoint()}`,
|
|
676
|
+
`Endpoint source: ${endpointSourceLabel()}`,
|
|
677
|
+
`Launch mode: ${launchModeLabel()}`,
|
|
678
|
+
...launchAttemptLines(),
|
|
592
679
|
].join("\n");
|
|
593
680
|
}
|
|
594
681
|
|
|
595
682
|
function buildQuickstartMessage() {
|
|
596
683
|
return [
|
|
597
684
|
`Chrome DevTools endpoint: ${devToolsEndpoint()}`,
|
|
685
|
+
`Endpoint source: ${endpointSourceLabel()}`,
|
|
686
|
+
`Launch mode: ${launchModeLabel()}`,
|
|
598
687
|
launchHint(),
|
|
688
|
+
browserCandidateHint(),
|
|
689
|
+
...launchAttemptLines(),
|
|
599
690
|
endpointConfigHint(),
|
|
600
691
|
].join("\n");
|
|
601
692
|
}
|
|
@@ -752,11 +843,23 @@ function unique<T>(values: T[]) {
|
|
|
752
843
|
return Array.from(new Set(values));
|
|
753
844
|
}
|
|
754
845
|
|
|
846
|
+
function parseConfiguredPort(value: string | undefined) {
|
|
847
|
+
if (value === undefined) return undefined;
|
|
848
|
+
const trimmedValue = value.trim();
|
|
849
|
+
if (!/^\d+$/.test(trimmedValue)) return undefined;
|
|
850
|
+
|
|
851
|
+
const port = Number(trimmedValue);
|
|
852
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined;
|
|
853
|
+
return port;
|
|
854
|
+
}
|
|
855
|
+
|
|
755
856
|
async function listPages(options: { waitMs?: number } = {}) {
|
|
857
|
+
const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
|
|
858
|
+
await ensureDevToolsEndpoint(waitMs);
|
|
756
859
|
return withEndpointRetry(async () => {
|
|
757
860
|
const pages = await fetchDevToolsJson<DevToolsPage[]>("/json/list");
|
|
758
861
|
return pages.filter((page) => page.type === "page" && page.webSocketDebuggerUrl);
|
|
759
|
-
},
|
|
862
|
+
}, waitMs);
|
|
760
863
|
}
|
|
761
864
|
|
|
762
865
|
async function getPage(pageId: string) {
|
|
@@ -817,10 +920,16 @@ function requirePage(pageId: string, pages: DevToolsPage[]) {
|
|
|
817
920
|
);
|
|
818
921
|
}
|
|
819
922
|
|
|
820
|
-
async function createPage(url: string) {
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
923
|
+
async function createPage(url: string, options: { waitMs?: number } = {}) {
|
|
924
|
+
const waitMs = options.waitMs ?? DEFAULT_ENDPOINT_WAIT_MS;
|
|
925
|
+
await ensureDevToolsEndpoint(waitMs);
|
|
926
|
+
const page = await withEndpointRetry(
|
|
927
|
+
() =>
|
|
928
|
+
fetchDevToolsJson<DevToolsPage>(`/json/new?${encodeURIComponent(url)}`, {
|
|
929
|
+
method: "PUT",
|
|
930
|
+
}),
|
|
931
|
+
waitMs,
|
|
932
|
+
);
|
|
824
933
|
if (page.type !== "page" || !page.webSocketDebuggerUrl) {
|
|
825
934
|
throw new Error("Chrome DevTools created a target that is not an inspectable page.");
|
|
826
935
|
}
|
|
@@ -828,6 +937,268 @@ async function createPage(url: string) {
|
|
|
828
937
|
return page;
|
|
829
938
|
}
|
|
830
939
|
|
|
940
|
+
async function ensureDevToolsEndpoint(waitMs = DEFAULT_ENDPOINT_WAIT_MS) {
|
|
941
|
+
if (canAutoLaunchBrowser()) {
|
|
942
|
+
try {
|
|
943
|
+
await withEndpointRetry(() => fetchDevToolsJson<unknown>("/json/version"), waitMs);
|
|
944
|
+
return;
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (shouldAutoLaunchAfterEndpointError(error)) {
|
|
947
|
+
await ensureManagedBrowserLaunched(waitMs);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
throw error;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
try {
|
|
955
|
+
await fetchDevToolsJson<unknown>("/json/version");
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (isRetryableEndpointError(error)) return;
|
|
958
|
+
throw error;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
async function ensureManagedBrowserLaunched(waitMs: number) {
|
|
963
|
+
if (state.launchPromise) return state.launchPromise;
|
|
964
|
+
if (state.managedBrowser && !state.managedBrowser.exited && state.managedBrowser.ready) return;
|
|
965
|
+
if (state.managedBrowser) {
|
|
966
|
+
await shutdownManagedBrowser(state.managedBrowser, { awaitLaunch: false });
|
|
967
|
+
}
|
|
968
|
+
throwIfBrowserLaunchCancelled();
|
|
969
|
+
|
|
970
|
+
state.launchPromise = launchManagedBrowser(waitMs).finally(() => {
|
|
971
|
+
state.launchPromise = undefined;
|
|
972
|
+
});
|
|
973
|
+
return state.launchPromise;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function launchManagedBrowser(waitMs: number) {
|
|
977
|
+
throwIfBrowserLaunchCancelled();
|
|
978
|
+
const candidateDefinitions = browserCandidateDefinitions();
|
|
979
|
+
const candidates = await resolveBrowserCandidates(candidateDefinitions);
|
|
980
|
+
throwIfBrowserLaunchCancelled();
|
|
981
|
+
state.lastLaunchAttempt = {
|
|
982
|
+
candidateLabels: candidateDefinitions.map(formatBrowserCandidateDefinition),
|
|
983
|
+
mode: state.portConfigured ? "explicit-port" : "dynamic-port",
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
if (candidates.length === 0) {
|
|
987
|
+
throw new DevToolsEndpointError(noBrowserCandidateMessage(candidateDefinitions));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
let lastError: unknown;
|
|
991
|
+
for (const candidate of candidates) {
|
|
992
|
+
throwIfBrowserLaunchCancelled();
|
|
993
|
+
try {
|
|
994
|
+
await launchBrowserCandidate(candidate, waitMs);
|
|
995
|
+
state.lastLaunchAttempt = {
|
|
996
|
+
...state.lastLaunchAttempt,
|
|
997
|
+
selectedCandidate: formatBrowserCandidate(candidate),
|
|
998
|
+
userDataDir: state.managedBrowser?.userDataDir,
|
|
999
|
+
};
|
|
1000
|
+
return;
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
lastError = error;
|
|
1003
|
+
state.lastLaunchAttempt = {
|
|
1004
|
+
...state.lastLaunchAttempt,
|
|
1005
|
+
lastError: formatError(error),
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
throw new DevToolsEndpointError(
|
|
1011
|
+
[
|
|
1012
|
+
"Unable to auto-launch a Chromium-family browser for Chrome DevTools.",
|
|
1013
|
+
`Tried: ${candidates.map(formatBrowserCandidate).join(", ")}`,
|
|
1014
|
+
lastError ? `Last error: ${formatError(lastError)}` : undefined,
|
|
1015
|
+
launchHint(),
|
|
1016
|
+
endpointConfigHint(),
|
|
1017
|
+
]
|
|
1018
|
+
.filter(Boolean)
|
|
1019
|
+
.join("\n"),
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function launchBrowserCandidate(candidate: BrowserCandidate, waitMs: number) {
|
|
1024
|
+
throwIfBrowserLaunchCancelled();
|
|
1025
|
+
const userDataDir = await mkdtemp(join(tmpdir(), MANAGED_BROWSER_PROFILE_PREFIX));
|
|
1026
|
+
let managedBrowser: ManagedBrowser | undefined;
|
|
1027
|
+
try {
|
|
1028
|
+
const portArgument = state.portConfigured ? String(state.port) : "0";
|
|
1029
|
+
const args = [
|
|
1030
|
+
`--remote-debugging-port=${portArgument}`,
|
|
1031
|
+
`--user-data-dir=${userDataDir}`,
|
|
1032
|
+
"--no-first-run",
|
|
1033
|
+
"--no-default-browser-check",
|
|
1034
|
+
"about:blank",
|
|
1035
|
+
];
|
|
1036
|
+
throwIfBrowserLaunchCancelled();
|
|
1037
|
+
const child = spawn(candidate.resolvedExecutable, args, { shell: false, stdio: "ignore" });
|
|
1038
|
+
const launchedBrowser: ManagedBrowser = { process: child, userDataDir, exited: false, ready: false };
|
|
1039
|
+
managedBrowser = launchedBrowser;
|
|
1040
|
+
state.managedBrowser = launchedBrowser;
|
|
1041
|
+
|
|
1042
|
+
child.once("exit", () => {
|
|
1043
|
+
launchedBrowser.exited = true;
|
|
1044
|
+
launchedBrowser.ready = false;
|
|
1045
|
+
if (!state.portConfigured && launchedBrowser.port === state.port) {
|
|
1046
|
+
state.port = state.configuredPort;
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
await waitForBrowserSpawn(child);
|
|
1051
|
+
if (state.portConfigured) {
|
|
1052
|
+
launchedBrowser.port = state.port;
|
|
1053
|
+
} else {
|
|
1054
|
+
launchedBrowser.port = await readManagedBrowserPort(userDataDir, launchedBrowser, waitMs);
|
|
1055
|
+
state.port = launchedBrowser.port;
|
|
1056
|
+
}
|
|
1057
|
+
await waitForDevToolsEndpoint(waitMs, launchedBrowser);
|
|
1058
|
+
launchedBrowser.ready = true;
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
if (managedBrowser) await shutdownManagedBrowser(managedBrowser, { awaitLaunch: false });
|
|
1061
|
+
else await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1062
|
+
throw error;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function waitForBrowserSpawn(child: ChildProcess) {
|
|
1067
|
+
return new Promise<void>((resolve, reject) => {
|
|
1068
|
+
let settled = false;
|
|
1069
|
+
const settle = (callback: () => void) => {
|
|
1070
|
+
if (settled) return;
|
|
1071
|
+
settled = true;
|
|
1072
|
+
child.off("error", onError);
|
|
1073
|
+
child.off("spawn", onSpawn);
|
|
1074
|
+
callback();
|
|
1075
|
+
};
|
|
1076
|
+
const onError = (error: Error) => settle(() => reject(error));
|
|
1077
|
+
const onSpawn = () => settle(resolve);
|
|
1078
|
+
child.once("error", onError);
|
|
1079
|
+
child.once("spawn", onSpawn);
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
async function readManagedBrowserPort(
|
|
1084
|
+
userDataDir: string,
|
|
1085
|
+
managedBrowser: ManagedBrowser,
|
|
1086
|
+
waitMs: number,
|
|
1087
|
+
) {
|
|
1088
|
+
const activePortFile = join(userDataDir, DEVTOOLS_ACTIVE_PORT_FILE);
|
|
1089
|
+
const deadline = Date.now() + waitMs;
|
|
1090
|
+
while (true) {
|
|
1091
|
+
throwIfManagedBrowserExited(managedBrowser);
|
|
1092
|
+
const text = await readFile(activePortFile, "utf8").catch((error: unknown) => {
|
|
1093
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
1094
|
+
throw error;
|
|
1095
|
+
});
|
|
1096
|
+
const portText = text?.split(/\r?\n/, 1)[0]?.trim();
|
|
1097
|
+
const port = Number(portText);
|
|
1098
|
+
if (Number.isInteger(port) && port > 0) return port;
|
|
1099
|
+
|
|
1100
|
+
throwIfBrowserLaunchCancelled();
|
|
1101
|
+
const remainingMs = deadline - Date.now();
|
|
1102
|
+
if (remainingMs <= 0) {
|
|
1103
|
+
throw new DevToolsEndpointError(
|
|
1104
|
+
[
|
|
1105
|
+
"Timed out waiting for auto-launched browser DevToolsActivePort.",
|
|
1106
|
+
`Expected file: ${activePortFile}`,
|
|
1107
|
+
launchHint(),
|
|
1108
|
+
].join("\n"),
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function waitForDevToolsEndpoint(waitMs: number, managedBrowser: ManagedBrowser) {
|
|
1116
|
+
const deadline = Date.now() + waitMs;
|
|
1117
|
+
while (true) {
|
|
1118
|
+
throwIfManagedBrowserExited(managedBrowser);
|
|
1119
|
+
try {
|
|
1120
|
+
await fetchDevToolsJson<unknown>("/json/version");
|
|
1121
|
+
return;
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (!isRetryableEndpointError(error)) throw error;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
throwIfBrowserLaunchCancelled();
|
|
1127
|
+
const remainingMs = deadline - Date.now();
|
|
1128
|
+
if (remainingMs <= 0) {
|
|
1129
|
+
throw new DevToolsEndpointError(
|
|
1130
|
+
[
|
|
1131
|
+
`Timed out waiting for auto-launched browser at ${devToolsEndpoint()}.`,
|
|
1132
|
+
launchHint(),
|
|
1133
|
+
].join("\n"),
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
await sleep(Math.min(DEFAULT_ENDPOINT_RETRY_MS, remainingMs));
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function throwIfManagedBrowserExited(managedBrowser: ManagedBrowser) {
|
|
1141
|
+
if (!managedBrowser.exited) return;
|
|
1142
|
+
throw new DevToolsEndpointError("Auto-launched browser exited before DevTools became available.");
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function throwIfBrowserLaunchCancelled() {
|
|
1146
|
+
if (!state.shuttingDown) return;
|
|
1147
|
+
throw new DevToolsEndpointError("Chrome DevTools browser launch cancelled during shutdown.");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function shutdownManagedBrowser(
|
|
1151
|
+
managedBrowser = state.managedBrowser,
|
|
1152
|
+
options: { awaitLaunch?: boolean; cancelLaunch?: boolean } = {},
|
|
1153
|
+
) {
|
|
1154
|
+
if (options.cancelLaunch) state.shuttingDown = true;
|
|
1155
|
+
if (options.awaitLaunch !== false) {
|
|
1156
|
+
await state.launchPromise?.catch(() => undefined);
|
|
1157
|
+
managedBrowser = managedBrowser ?? state.managedBrowser;
|
|
1158
|
+
}
|
|
1159
|
+
if (!managedBrowser) return;
|
|
1160
|
+
if (state.managedBrowser === managedBrowser) state.managedBrowser = undefined;
|
|
1161
|
+
|
|
1162
|
+
if (!managedBrowser.exited) {
|
|
1163
|
+
killManagedBrowserProcess(managedBrowser);
|
|
1164
|
+
await waitForManagedBrowserExit(managedBrowser, BROWSER_SHUTDOWN_WAIT_MS).catch(() => {
|
|
1165
|
+
killManagedBrowserProcess(managedBrowser, "SIGKILL");
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
await rm(managedBrowser.userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1169
|
+
if (!state.portConfigured && managedBrowser.port === state.port) state.port = state.configuredPort;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function killManagedBrowserProcess(managedBrowser: ManagedBrowser, signal?: NodeJS.Signals) {
|
|
1173
|
+
try {
|
|
1174
|
+
managedBrowser.process.kill(signal);
|
|
1175
|
+
} catch {
|
|
1176
|
+
// Best-effort shutdown: the browser may have already exited or failed to spawn.
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function waitForManagedBrowserExit(managedBrowser: ManagedBrowser, waitMs: number) {
|
|
1181
|
+
if (managedBrowser.exited) return Promise.resolve();
|
|
1182
|
+
return new Promise<void>((resolve, reject) => {
|
|
1183
|
+
const settle = (callback: () => void) => {
|
|
1184
|
+
clearTimeout(timeout);
|
|
1185
|
+
managedBrowser.process.off("exit", onExitOrClose);
|
|
1186
|
+
managedBrowser.process.off("close", onExitOrClose);
|
|
1187
|
+
callback();
|
|
1188
|
+
};
|
|
1189
|
+
const onExitOrClose = () => {
|
|
1190
|
+
managedBrowser.exited = true;
|
|
1191
|
+
settle(resolve);
|
|
1192
|
+
};
|
|
1193
|
+
const timeout = setTimeout(
|
|
1194
|
+
() => settle(() => reject(new Error("Timed out waiting for browser shutdown."))),
|
|
1195
|
+
waitMs,
|
|
1196
|
+
);
|
|
1197
|
+
managedBrowser.process.once("exit", onExitOrClose);
|
|
1198
|
+
managedBrowser.process.once("close", onExitOrClose);
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
831
1202
|
async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
|
|
832
1203
|
const url = `${devToolsEndpoint()}${path}`;
|
|
833
1204
|
let response: Response;
|
|
@@ -837,7 +1208,10 @@ async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
|
|
|
837
1208
|
signal: AbortSignal.timeout(DEFAULT_HTTP_TIMEOUT_MS),
|
|
838
1209
|
});
|
|
839
1210
|
} catch (error) {
|
|
840
|
-
throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
|
|
1211
|
+
throw new DevToolsEndpointError(endpointConnectionErrorMessage(error), {
|
|
1212
|
+
launchable: true,
|
|
1213
|
+
retryable: true,
|
|
1214
|
+
});
|
|
841
1215
|
}
|
|
842
1216
|
|
|
843
1217
|
if (!response.ok) {
|
|
@@ -852,7 +1226,16 @@ async function fetchDevToolsJson<T>(path: string, init?: RequestInit) {
|
|
|
852
1226
|
);
|
|
853
1227
|
}
|
|
854
1228
|
|
|
855
|
-
|
|
1229
|
+
try {
|
|
1230
|
+
return (await response.json()) as T;
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
throw new DevToolsEndpointError(
|
|
1233
|
+
[
|
|
1234
|
+
`Chrome DevTools endpoint ${url} returned invalid JSON: ${formatError(error)}.`,
|
|
1235
|
+
endpointConfigHint(),
|
|
1236
|
+
].join("\n"),
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
856
1239
|
}
|
|
857
1240
|
|
|
858
1241
|
async function withEndpointRetry<T>(operation: () => Promise<T>, waitMs: number) {
|
|
@@ -875,6 +1258,23 @@ function isRetryableEndpointError(error: unknown) {
|
|
|
875
1258
|
return error instanceof DevToolsEndpointError && error.retryable;
|
|
876
1259
|
}
|
|
877
1260
|
|
|
1261
|
+
function isLaunchableEndpointError(error: unknown) {
|
|
1262
|
+
return error instanceof DevToolsEndpointError && error.launchable;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function shouldAutoLaunchAfterEndpointError(error: unknown) {
|
|
1266
|
+
if (!canAutoLaunchBrowser()) return false;
|
|
1267
|
+
if (isLaunchableEndpointError(error)) return true;
|
|
1268
|
+
|
|
1269
|
+
// After the attach-first attempt (including its retry window, when applicable) fails, treat any
|
|
1270
|
+
// DevTools endpoint error on an unpinned port as a conflict we can avoid with a dynamic port.
|
|
1271
|
+
return !state.portConfigured && error instanceof DevToolsEndpointError;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function canAutoLaunchBrowser() {
|
|
1275
|
+
return state.autoLaunchEnabled && isLocalDevToolsHost(state.host);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
878
1278
|
function endpointConnectionErrorMessage(error: unknown) {
|
|
879
1279
|
const reason = isTimeoutError(error) ? "request timed out" : "connection failed";
|
|
880
1280
|
return [
|
|
@@ -889,26 +1289,260 @@ function isTimeoutError(error: unknown) {
|
|
|
889
1289
|
}
|
|
890
1290
|
|
|
891
1291
|
function devToolsEndpoint() {
|
|
892
|
-
return `http://${state.host}:${state.port}`;
|
|
1292
|
+
return `http://${formatHostForUrl(state.host)}:${state.port}`;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function formatHostForUrl(host: string) {
|
|
1296
|
+
if (host.startsWith("[") && host.endsWith("]")) return host;
|
|
1297
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function endpointSourceLabel() {
|
|
1301
|
+
const hostSource = state.hostConfigured ? "PI_CHROME_DEVTOOLS_HOST" : "default host";
|
|
1302
|
+
const portSource = state.portConfigured ? "PI_CHROME_DEVTOOLS_PORT" : "default/dynamic port";
|
|
1303
|
+
return `${hostSource}; ${portSource}`;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function launchModeLabel() {
|
|
1307
|
+
if (!isLocalDevToolsHost(state.host)) return "manual remote endpoint";
|
|
1308
|
+
if (!state.autoLaunchEnabled) return "manual; auto-launch disabled";
|
|
1309
|
+
if (state.managedBrowser && !state.managedBrowser.exited) {
|
|
1310
|
+
return state.portConfigured ? "auto-launched on explicit port" : "auto-launched on dynamic port";
|
|
1311
|
+
}
|
|
1312
|
+
return state.portConfigured ? "attach first; auto-launch explicit port" : "attach first; auto-launch dynamic port";
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function launchAttemptLines() {
|
|
1316
|
+
if (!state.lastLaunchAttempt) return [];
|
|
1317
|
+
|
|
1318
|
+
const lines = [`Last launch attempt: ${state.lastLaunchAttempt.mode}`];
|
|
1319
|
+
if (state.lastLaunchAttempt.selectedCandidate) {
|
|
1320
|
+
lines.push(`Launched browser: ${state.lastLaunchAttempt.selectedCandidate}`);
|
|
1321
|
+
} else {
|
|
1322
|
+
lines.push(`Tried browser candidates: ${state.lastLaunchAttempt.candidateLabels.join(", ")}`);
|
|
1323
|
+
}
|
|
1324
|
+
if (state.lastLaunchAttempt.userDataDir) {
|
|
1325
|
+
lines.push(`Managed browser profile: ${state.lastLaunchAttempt.userDataDir}`);
|
|
1326
|
+
}
|
|
1327
|
+
if (state.lastLaunchAttempt.lastError) {
|
|
1328
|
+
lines.push(`Last launch error: ${state.lastLaunchAttempt.lastError}`);
|
|
1329
|
+
}
|
|
1330
|
+
return lines;
|
|
893
1331
|
}
|
|
894
1332
|
|
|
895
1333
|
function launchHint() {
|
|
896
|
-
|
|
1334
|
+
if (!isLocalDevToolsHost(state.host)) {
|
|
1335
|
+
return `Remote/non-local endpoints are not auto-launched. Start a browser with CDP enabled at ${devToolsEndpoint()}.`;
|
|
1336
|
+
}
|
|
1337
|
+
if (!state.autoLaunchEnabled) {
|
|
1338
|
+
return `Auto-launch is disabled. Start a browser manually: ${chromeLaunchCommand()}`;
|
|
1339
|
+
}
|
|
1340
|
+
const managedMode = state.portConfigured ? `port ${state.port}` : "a dynamic DevTools port";
|
|
1341
|
+
return `If no endpoint is available, Pi will auto-launch a Chromium-family browser with ${managedMode} and an isolated temp profile. Manual command: ${chromeLaunchCommand()}`;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function browserCandidateHint() {
|
|
1345
|
+
return `Browser candidates: ${browserCandidateDefinitions().map((candidate) => candidate.label).join(", ")}`;
|
|
897
1346
|
}
|
|
898
1347
|
|
|
899
1348
|
function chromeLaunchCommand() {
|
|
900
|
-
const executable =
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
return
|
|
1349
|
+
const executable = state.browserExecutable ?? defaultManualBrowserExecutable();
|
|
1350
|
+
const dataDir =
|
|
1351
|
+
process.platform === "win32" ? "%TEMP%\\pi-chrome-devtools" : "/tmp/pi-chrome-devtools";
|
|
1352
|
+
return `${quoteCommandPart(executable)} --remote-debugging-port=${state.port} --user-data-dir=${dataDir}`;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function defaultManualBrowserExecutable() {
|
|
1356
|
+
return process.platform === "darwin"
|
|
1357
|
+
? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
1358
|
+
: process.platform === "win32"
|
|
1359
|
+
? "chrome.exe"
|
|
1360
|
+
: "google-chrome";
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function quoteCommandPart(value: string) {
|
|
1364
|
+
return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value;
|
|
908
1365
|
}
|
|
909
1366
|
|
|
910
1367
|
function endpointConfigHint() {
|
|
911
|
-
return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT
|
|
1368
|
+
return "Set PI_CHROME_DEVTOOLS_HOST and PI_CHROME_DEVTOOLS_PORT for a manual endpoint, PI_CHROME_DEVTOOLS_BROWSER to choose an executable, or PI_CHROME_DEVTOOLS_AUTO_LAUNCH=0 to disable auto-launch.";
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function isLocalDevToolsHost(host: string) {
|
|
1372
|
+
const normalizedHost = host.toLowerCase().replace(/^\[(.*)]$/, "$1");
|
|
1373
|
+
return ["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"].includes(normalizedHost);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function browserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
|
1377
|
+
const explicitCandidate = explicitBrowserCandidateDefinition();
|
|
1378
|
+
if (explicitCandidate.length > 0) return explicitCandidate;
|
|
1379
|
+
|
|
1380
|
+
return uniqueBrowserCandidates(platformBrowserCandidateDefinitions());
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function explicitBrowserCandidateDefinition(): BrowserCandidateDefinition[] {
|
|
1384
|
+
if (!state.browserExecutable) return [];
|
|
1385
|
+
return [{ label: "PI_CHROME_DEVTOOLS_BROWSER", executable: state.browserExecutable, source: "env" }];
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function platformBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
|
1389
|
+
if (process.platform === "darwin") {
|
|
1390
|
+
return [
|
|
1391
|
+
{
|
|
1392
|
+
label: "Google Chrome",
|
|
1393
|
+
executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1394
|
+
source: "wellKnownPath",
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
label: "Chromium",
|
|
1398
|
+
executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
1399
|
+
source: "wellKnownPath",
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
label: "Brave Browser",
|
|
1403
|
+
executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
1404
|
+
source: "wellKnownPath",
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
label: "Microsoft Edge",
|
|
1408
|
+
executable: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
1409
|
+
source: "wellKnownPath",
|
|
1410
|
+
},
|
|
1411
|
+
];
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (process.platform === "win32") {
|
|
1415
|
+
return windowsBrowserCandidateDefinitions();
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
return [
|
|
1419
|
+
{ label: "Google Chrome", executable: "google-chrome", source: "path" },
|
|
1420
|
+
{ label: "Google Chrome Stable", executable: "google-chrome-stable", source: "path" },
|
|
1421
|
+
{ label: "Chromium", executable: "chromium", source: "path" },
|
|
1422
|
+
{ label: "Chromium Browser", executable: "chromium-browser", source: "path" },
|
|
1423
|
+
{ label: "Brave Browser", executable: "brave-browser", source: "path" },
|
|
1424
|
+
{ label: "Brave", executable: "brave", source: "path" },
|
|
1425
|
+
{ label: "Microsoft Edge", executable: "microsoft-edge", source: "path" },
|
|
1426
|
+
{ label: "Microsoft Edge Stable", executable: "microsoft-edge-stable", source: "path" },
|
|
1427
|
+
];
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function windowsBrowserCandidateDefinitions(): BrowserCandidateDefinition[] {
|
|
1431
|
+
const programFiles = [
|
|
1432
|
+
process.env.PROGRAMFILES,
|
|
1433
|
+
process.env["PROGRAMFILES(X86)"],
|
|
1434
|
+
process.env.LOCALAPPDATA,
|
|
1435
|
+
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
1436
|
+
const wellKnownPaths = programFiles.flatMap((root) => [
|
|
1437
|
+
join(root, "Google", "Chrome", "Application", "chrome.exe"),
|
|
1438
|
+
join(root, "Chromium", "Application", "chrome.exe"),
|
|
1439
|
+
join(root, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
1440
|
+
join(root, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
1441
|
+
]);
|
|
1442
|
+
return [
|
|
1443
|
+
...wellKnownPaths.map((executable) => ({
|
|
1444
|
+
label: browserLabelFromExecutable(executable),
|
|
1445
|
+
executable,
|
|
1446
|
+
source: "wellKnownPath" as const,
|
|
1447
|
+
})),
|
|
1448
|
+
{ label: "Google Chrome", executable: "chrome.exe", source: "path" },
|
|
1449
|
+
{ label: "Chromium", executable: "chromium.exe", source: "path" },
|
|
1450
|
+
{ label: "Brave Browser", executable: "brave.exe", source: "path" },
|
|
1451
|
+
{ label: "Microsoft Edge", executable: "msedge.exe", source: "path" },
|
|
1452
|
+
];
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function browserLabelFromExecutable(executable: string) {
|
|
1456
|
+
const normalizedExecutable = normalizePathForComparison(executable);
|
|
1457
|
+
if (normalizedExecutable.includes("brave")) return "Brave Browser";
|
|
1458
|
+
if (normalizedExecutable.includes("edge") || normalizedExecutable.includes("msedge")) {
|
|
1459
|
+
return "Microsoft Edge";
|
|
1460
|
+
}
|
|
1461
|
+
if (normalizedExecutable.includes("chromium")) return "Chromium";
|
|
1462
|
+
return "Google Chrome";
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function uniqueBrowserCandidates(candidates: BrowserCandidateDefinition[]) {
|
|
1466
|
+
const seen = new Set<string>();
|
|
1467
|
+
return candidates.filter((candidate) => {
|
|
1468
|
+
const key = normalizePathForComparison(candidate.executable);
|
|
1469
|
+
if (seen.has(key)) return false;
|
|
1470
|
+
seen.add(key);
|
|
1471
|
+
return true;
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
async function resolveBrowserCandidates(definitions: BrowserCandidateDefinition[]) {
|
|
1476
|
+
const candidates: BrowserCandidate[] = [];
|
|
1477
|
+
for (const definition of definitions) {
|
|
1478
|
+
const resolvedExecutable = await resolveBrowserExecutable(definition.executable);
|
|
1479
|
+
if (!resolvedExecutable) continue;
|
|
1480
|
+
candidates.push({ ...definition, resolvedExecutable });
|
|
1481
|
+
}
|
|
1482
|
+
return uniqueBrowserCandidatesByResolvedPath(candidates);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function uniqueBrowserCandidatesByResolvedPath(candidates: BrowserCandidate[]) {
|
|
1486
|
+
const seen = new Set<string>();
|
|
1487
|
+
return candidates.filter((candidate) => {
|
|
1488
|
+
const key = normalizePathForComparison(resolve(candidate.resolvedExecutable));
|
|
1489
|
+
if (seen.has(key)) return false;
|
|
1490
|
+
seen.add(key);
|
|
1491
|
+
return true;
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function resolveBrowserExecutable(executable: string) {
|
|
1496
|
+
if (hasPathSeparator(executable) || isAbsolute(executable)) {
|
|
1497
|
+
const resolvedExecutable = isAbsolute(executable) ? executable : resolve(executable);
|
|
1498
|
+
return (await canAccessExecutable(resolvedExecutable)) ? resolvedExecutable : undefined;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
for (const directory of executableSearchPath()) {
|
|
1502
|
+
for (const executableName of executableSearchNames(executable)) {
|
|
1503
|
+
const candidate = join(directory, executableName);
|
|
1504
|
+
if (await canAccessExecutable(candidate)) return candidate;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return undefined;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function hasPathSeparator(path: string) {
|
|
1511
|
+
return path.includes("/") || path.includes("\\");
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function executableSearchPath() {
|
|
1515
|
+
return (process.env.PATH ?? "").split(delimiter).filter((part) => part.length > 0);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function executableSearchNames(executable: string) {
|
|
1519
|
+
if (process.platform !== "win32" || /\.[a-z0-9]+$/i.test(executable)) return [executable];
|
|
1520
|
+
return [executable, `${executable}.exe`, `${executable}.cmd`, `${executable}.bat`];
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function canAccessExecutable(path: string) {
|
|
1524
|
+
try {
|
|
1525
|
+
await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
1526
|
+
return true;
|
|
1527
|
+
} catch {
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function formatBrowserCandidate(candidate: BrowserCandidate) {
|
|
1533
|
+
return `${candidate.label} (${candidate.resolvedExecutable})`;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function formatBrowserCandidateDefinition(candidate: BrowserCandidateDefinition) {
|
|
1537
|
+
return `${candidate.label} (${candidate.executable})`;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
function noBrowserCandidateMessage(candidateDefinitions: BrowserCandidateDefinition[]) {
|
|
1541
|
+
return [
|
|
1542
|
+
"Cannot auto-launch Chrome DevTools because no Chromium-family browser executable was found.",
|
|
1543
|
+
`Tried: ${candidateDefinitions.map(formatBrowserCandidateDefinition).join(", ")}`,
|
|
1544
|
+
endpointConfigHint(),
|
|
1545
|
+
].join("\n");
|
|
912
1546
|
}
|
|
913
1547
|
|
|
914
1548
|
function formatPageListItem(page: DevToolsPage) {
|
|
@@ -921,11 +1555,13 @@ function sleep(ms: number) {
|
|
|
921
1555
|
|
|
922
1556
|
class DevToolsEndpointError extends Error {
|
|
923
1557
|
readonly retryable: boolean;
|
|
1558
|
+
readonly launchable: boolean;
|
|
924
1559
|
|
|
925
|
-
constructor(message: string, options: { retryable?: boolean } = {}) {
|
|
1560
|
+
constructor(message: string, options: { retryable?: boolean; launchable?: boolean } = {}) {
|
|
926
1561
|
super(message);
|
|
927
1562
|
this.name = "DevToolsEndpointError";
|
|
928
1563
|
this.retryable = options.retryable ?? false;
|
|
1564
|
+
this.launchable = options.launchable ?? false;
|
|
929
1565
|
}
|
|
930
1566
|
}
|
|
931
1567
|
|
|
@@ -945,6 +1581,233 @@ function textResult(text: string, details: unknown) {
|
|
|
945
1581
|
};
|
|
946
1582
|
}
|
|
947
1583
|
|
|
1584
|
+
async function saveScreenshot(
|
|
1585
|
+
base64Png: string,
|
|
1586
|
+
savePath: string | undefined,
|
|
1587
|
+
cwd: string,
|
|
1588
|
+
signal: AbortSignal | undefined,
|
|
1589
|
+
): Promise<ScreenshotSaveResult> {
|
|
1590
|
+
const resolvedPath = resolveScreenshotPath(savePath, cwd);
|
|
1591
|
+
const pngBytes = Buffer.from(base64Png, "base64");
|
|
1592
|
+
|
|
1593
|
+
await withFileMutationQueue(resolvedPath.path, async () => {
|
|
1594
|
+
throwIfAborted(signal);
|
|
1595
|
+
await ensureSafeScreenshotParent(resolvedPath);
|
|
1596
|
+
await assertSafeScreenshotTargetPath(resolvedPath);
|
|
1597
|
+
await writeScreenshotFileSafely(resolvedPath, pngBytes, signal);
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
return {
|
|
1601
|
+
savedPath: resolvedPath.path,
|
|
1602
|
+
bytes: pngBytes.byteLength,
|
|
1603
|
+
isDefaultPath: resolvedPath.isDefault,
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function resolveScreenshotPath(savePath: string | undefined, cwd: string): ResolvedScreenshotPath {
|
|
1608
|
+
const cwdRoot = resolve(cwd);
|
|
1609
|
+
const tempRoot = resolve(tmpdir());
|
|
1610
|
+
|
|
1611
|
+
if (savePath === undefined) {
|
|
1612
|
+
return {
|
|
1613
|
+
path: join(tempRoot, `pi-chrome-devtools-screenshot-${randomUUID()}.png`),
|
|
1614
|
+
allowedRoots: [tempRoot],
|
|
1615
|
+
isDefault: true,
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const normalizedPath = stripLeadingAtPath(savePath);
|
|
1620
|
+
if (!normalizedPath.trim()) {
|
|
1621
|
+
throw new Error("Screenshot savePath must not be empty.");
|
|
1622
|
+
}
|
|
1623
|
+
if (normalizedPath.includes("\0")) {
|
|
1624
|
+
throw new Error("Screenshot savePath must not contain NUL bytes.");
|
|
1625
|
+
}
|
|
1626
|
+
if (hasParentPathSegment(normalizedPath)) {
|
|
1627
|
+
throw new Error("Screenshot savePath must not contain '..' path segments.");
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const isAbsolutePath = isAbsolute(normalizedPath);
|
|
1631
|
+
const path = isAbsolutePath ? resolve(normalizedPath) : resolve(cwdRoot, normalizedPath);
|
|
1632
|
+
const allowedRoots = isAbsolutePath ? unique([cwdRoot, tempRoot]) : [cwdRoot];
|
|
1633
|
+
if (!allowedRoots.some((root) => isPathInsideRoot(path, root))) {
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
"Screenshot savePath must be relative to the current working directory, or an absolute path inside the current working directory or OS temp directory.",
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
return { path, allowedRoots, isDefault: false };
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function stripLeadingAtPath(path: string) {
|
|
1643
|
+
return path.startsWith("@") ? path.slice(1) : path;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function hasParentPathSegment(path: string) {
|
|
1647
|
+
return path.split(/[\\/]+/).some((part) => part === "..");
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
async function ensureSafeScreenshotParent(resolvedPath: ResolvedScreenshotPath) {
|
|
1651
|
+
const parentPath = dirname(resolvedPath.path);
|
|
1652
|
+
const rootPath = selectAllowedRoot(parentPath, resolvedPath.allowedRoots);
|
|
1653
|
+
if (!rootPath) {
|
|
1654
|
+
throw new Error(
|
|
1655
|
+
"Screenshot savePath parent must stay inside the current working directory or OS temp directory.",
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const realRootPath = await realpath(rootPath);
|
|
1660
|
+
let currentPath = rootPath;
|
|
1661
|
+
const parentSegments = relative(rootPath, parentPath)
|
|
1662
|
+
.split(/[\\/]+/)
|
|
1663
|
+
.filter((part) => part.length > 0);
|
|
1664
|
+
|
|
1665
|
+
for (const segment of parentSegments) {
|
|
1666
|
+
currentPath = join(currentPath, segment);
|
|
1667
|
+
await ensureSafeDirectorySegment(currentPath, realRootPath);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function selectAllowedRoot(path: string, roots: readonly string[]) {
|
|
1672
|
+
const matchingRoots = roots.filter((root) => isPathInsideRoot(path, root));
|
|
1673
|
+
matchingRoots.sort(
|
|
1674
|
+
(left, right) =>
|
|
1675
|
+
normalizePathForComparison(resolve(right)).length -
|
|
1676
|
+
normalizePathForComparison(resolve(left)).length,
|
|
1677
|
+
);
|
|
1678
|
+
return matchingRoots[0];
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function ensureSafeDirectorySegment(path: string, realRootPath: string) {
|
|
1682
|
+
const existingDirectory = await lstat(path).catch(async (error: unknown) => {
|
|
1683
|
+
if (!isNodeError(error) || error.code !== "ENOENT") throw error;
|
|
1684
|
+
await mkdir(path).catch((mkdirError: unknown) => {
|
|
1685
|
+
if (!isNodeError(mkdirError) || mkdirError.code !== "EEXIST") throw mkdirError;
|
|
1686
|
+
});
|
|
1687
|
+
return lstat(path);
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
if (existingDirectory.isSymbolicLink()) {
|
|
1691
|
+
throw new Error("Screenshot savePath parent directories must not contain symbolic links.");
|
|
1692
|
+
}
|
|
1693
|
+
if (!existingDirectory.isDirectory()) {
|
|
1694
|
+
throw new Error("Screenshot savePath parent must be a directory.");
|
|
1695
|
+
}
|
|
1696
|
+
await assertPathWithinRealRoot(path, realRootPath);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
async function assertSafeScreenshotTargetPath(resolvedPath: ResolvedScreenshotPath) {
|
|
1700
|
+
const existingTarget = await lstat(resolvedPath.path).catch((error: unknown) => {
|
|
1701
|
+
if (isNodeError(error) && error.code === "ENOENT") return undefined;
|
|
1702
|
+
throw error;
|
|
1703
|
+
});
|
|
1704
|
+
if (existingTarget?.isSymbolicLink()) {
|
|
1705
|
+
throw new Error("Screenshot savePath must not point to a symbolic link.");
|
|
1706
|
+
}
|
|
1707
|
+
if (existingTarget?.isDirectory()) {
|
|
1708
|
+
throw new Error("Screenshot savePath must point to a file, not a directory.");
|
|
1709
|
+
}
|
|
1710
|
+
if (existingTarget && !existingTarget.isFile()) {
|
|
1711
|
+
throw new Error("Screenshot savePath may only replace regular files.");
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const realAllowedRoots = await Promise.all(resolvedPath.allowedRoots.map(realpathOrResolvedPath));
|
|
1715
|
+
const realParent = await realpath(dirname(resolvedPath.path));
|
|
1716
|
+
const realTargetPath = join(realParent, basename(resolvedPath.path));
|
|
1717
|
+
if (!realAllowedRoots.some((root) => isPathInsideRoot(realTargetPath, root))) {
|
|
1718
|
+
throw new Error(
|
|
1719
|
+
"Screenshot savePath resolves outside the current working directory or OS temp directory.",
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
async function assertPathWithinRealRoot(path: string, realRootPath: string) {
|
|
1725
|
+
const realPath = await realpath(path);
|
|
1726
|
+
if (!isPathInsideRoot(realPath, realRootPath)) {
|
|
1727
|
+
throw new Error(
|
|
1728
|
+
"Screenshot savePath parent resolves outside the current working directory or OS temp directory.",
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async function writeScreenshotFileSafely(
|
|
1734
|
+
resolvedPath: ResolvedScreenshotPath,
|
|
1735
|
+
pngBytes: Buffer,
|
|
1736
|
+
signal: AbortSignal | undefined,
|
|
1737
|
+
) {
|
|
1738
|
+
const tempFile = join(
|
|
1739
|
+
dirname(resolvedPath.path),
|
|
1740
|
+
`.${basename(resolvedPath.path)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`,
|
|
1741
|
+
);
|
|
1742
|
+
try {
|
|
1743
|
+
await writeFile(tempFile, pngBytes, { flag: "wx", signal });
|
|
1744
|
+
throwIfAborted(signal);
|
|
1745
|
+
await replaceScreenshotFile(resolvedPath, tempFile, signal);
|
|
1746
|
+
} catch (error) {
|
|
1747
|
+
await rm(tempFile, { force: true }).catch(() => undefined);
|
|
1748
|
+
throw error;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
async function replaceScreenshotFile(
|
|
1753
|
+
resolvedPath: ResolvedScreenshotPath,
|
|
1754
|
+
tempFile: string,
|
|
1755
|
+
signal: AbortSignal | undefined,
|
|
1756
|
+
) {
|
|
1757
|
+
try {
|
|
1758
|
+
await rename(tempFile, resolvedPath.path);
|
|
1759
|
+
return;
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
if (!shouldRetryRenameAfterRemovingDestination(error)) throw error;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// Some Windows filesystems reject renaming over an existing file. Revalidate before
|
|
1765
|
+
// removing the destination so the fallback still refuses directories and symlinks.
|
|
1766
|
+
await assertSafeScreenshotTargetPath(resolvedPath);
|
|
1767
|
+
throwIfAborted(signal);
|
|
1768
|
+
await rm(resolvedPath.path, { force: true });
|
|
1769
|
+
await rename(tempFile, resolvedPath.path);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function shouldRetryRenameAfterRemovingDestination(error: unknown) {
|
|
1773
|
+
return (
|
|
1774
|
+
process.platform === "win32" &&
|
|
1775
|
+
isNodeError(error) &&
|
|
1776
|
+
["EACCES", "EEXIST", "EPERM"].includes(error.code ?? "")
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function realpathOrResolvedPath(path: string) {
|
|
1781
|
+
return realpath(path).catch(() => resolve(path));
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function isPathInsideRoot(path: string, root: string) {
|
|
1785
|
+
const normalizedPath = normalizePathForComparison(resolve(path));
|
|
1786
|
+
const normalizedRoot = normalizePathForComparison(resolve(root));
|
|
1787
|
+
if (normalizedPath === normalizedRoot) return true;
|
|
1788
|
+
const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
|
|
1789
|
+
return normalizedPath.startsWith(rootWithSeparator);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function normalizePathForComparison(path: string) {
|
|
1793
|
+
return process.platform === "win32" ? path.toLowerCase() : path;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function throwIfAborted(signal: AbortSignal | undefined) {
|
|
1797
|
+
if (!signal?.aborted) return;
|
|
1798
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("Screenshot capture cancelled.");
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function formatScreenshotText(page: DevToolsPage, screenshot: ScreenshotSaveResult) {
|
|
1802
|
+
const pathLabel = screenshot.isDefaultPath ? "Saved to temp file" : "Saved to";
|
|
1803
|
+
return [
|
|
1804
|
+
`Captured PNG screenshot from ${page.title || page.url || page.id}.`,
|
|
1805
|
+
`${pathLabel}: ${screenshot.savedPath}`,
|
|
1806
|
+
`Bytes: ${screenshot.bytes}`,
|
|
1807
|
+
`Use read({ path: ${JSON.stringify(screenshot.savedPath)} }) to inspect the saved screenshot if inline image content is not available.`,
|
|
1808
|
+
].join("\n");
|
|
1809
|
+
}
|
|
1810
|
+
|
|
948
1811
|
function renderToolCall(action: string) {
|
|
949
1812
|
return () => new PiTextComponent(`Chrome DevTools: ${action}`);
|
|
950
1813
|
}
|
|
@@ -963,7 +1826,7 @@ function renderScreenshotResult(
|
|
|
963
1826
|
options: ToolRenderResultOptions,
|
|
964
1827
|
theme: RenderTheme,
|
|
965
1828
|
): RenderComponent {
|
|
966
|
-
const output = formatCollapsibleOutput(
|
|
1829
|
+
const output = formatCollapsibleOutput(screenshotTextContent(result), options);
|
|
967
1830
|
return new PiTextComponent(output.text, theme, output.color);
|
|
968
1831
|
}
|
|
969
1832
|
|
|
@@ -973,6 +1836,16 @@ function textContent(result: AgentToolResult<unknown>) {
|
|
|
973
1836
|
.join("\n");
|
|
974
1837
|
}
|
|
975
1838
|
|
|
1839
|
+
function screenshotTextContent(result: AgentToolResult<unknown>) {
|
|
1840
|
+
const text = textContent(result);
|
|
1841
|
+
if (text.trim()) return text;
|
|
1842
|
+
|
|
1843
|
+
const details = result.details as { savedPath?: unknown; bytes?: unknown } | undefined;
|
|
1844
|
+
if (typeof details?.savedPath !== "string") return text;
|
|
1845
|
+
const bytes = typeof details.bytes === "number" ? ` (${details.bytes} bytes)` : "";
|
|
1846
|
+
return `Saved screenshot to ${details.savedPath}${bytes}`;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
976
1849
|
function formatCollapsibleOutput(
|
|
977
1850
|
text: string,
|
|
978
1851
|
options: ToolRenderResultOptions,
|
|
@@ -984,11 +1857,15 @@ function formatCollapsibleOutput(
|
|
|
984
1857
|
}
|
|
985
1858
|
|
|
986
1859
|
class PiTextComponent implements RenderComponent {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
) {
|
|
1860
|
+
private text: string;
|
|
1861
|
+
private readonly theme?: RenderTheme;
|
|
1862
|
+
private readonly color?: string;
|
|
1863
|
+
|
|
1864
|
+
constructor(text = "", theme?: RenderTheme, color?: string) {
|
|
1865
|
+
this.text = text;
|
|
1866
|
+
this.theme = theme;
|
|
1867
|
+
this.color = color;
|
|
1868
|
+
}
|
|
992
1869
|
|
|
993
1870
|
setText(text: string) {
|
|
994
1871
|
this.text = text;
|