pi-browser-harness 0.3.1 → 0.4.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/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +32 -205
- package/src/cdp/network-buffer.ts +213 -0
- package/src/cdp/ownership.ts +72 -0
- package/src/cdp/session.ts +60 -14
- package/src/cdp/types.ts +1 -0
- package/src/client.ts +81 -4
- package/src/domains/click.ts +6 -5
- package/src/domains/js.ts +65 -6
- package/src/domains/navigate.ts +1 -1
- package/src/domains/network.ts +191 -20
- package/src/domains/screenshot.ts +44 -5
- package/src/domains/scroll.ts +16 -9
- package/src/domains/snapshot.ts +421 -0
- package/src/domains/tabs.ts +113 -39
- package/src/index.ts +17 -1
- package/src/prompt.ts +13 -3
- package/src/registry.ts +28 -22
- package/src/setup.ts +23 -5
- package/src/state.ts +6 -1
- package/src/util/mutex.ts +25 -0
- package/src/util/tool.ts +41 -17
package/src/domains/network.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { Markdown, Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
|
2
4
|
import { type Result, err, ok } from "../util/result";
|
|
3
5
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
4
6
|
import { applyTruncation } from "../util/truncate";
|
|
7
|
+
import type { NetworkRecord } from "../cdp/network-buffer";
|
|
5
8
|
|
|
6
9
|
const HttpGetArgs = Type.Object({
|
|
7
10
|
url: Type.String({ description: "URL to GET" }),
|
|
@@ -49,32 +52,200 @@ export const httpGetTool = defineBrowserTool({
|
|
|
49
52
|
},
|
|
50
53
|
});
|
|
51
54
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
Type.
|
|
55
|
+
const NetworkRequestsArgs = Type.Object({
|
|
56
|
+
urlPattern: Type.Optional(
|
|
57
|
+
Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /api\\\\.example/) for regex." }),
|
|
58
|
+
),
|
|
59
|
+
methodFilter: Type.Optional(
|
|
60
|
+
Type.Array(Type.String(), { description: 'HTTP methods to include, e.g. ["GET","POST"].' }),
|
|
61
|
+
),
|
|
62
|
+
statusFilter: Type.Optional(
|
|
63
|
+
Type.Object(
|
|
64
|
+
{
|
|
65
|
+
min: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
|
|
66
|
+
max: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
|
|
67
|
+
},
|
|
68
|
+
{ description: "Inclusive HTTP status range." },
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
resourceTypes: Type.Optional(
|
|
72
|
+
Type.Array(Type.String(), { description: 'CDP resource types: "Document","Stylesheet","Image","Media","Font","Script","TextTrack","XHR","Fetch","EventSource","WebSocket","Manifest","SignedExchange","Ping","CSPViolationReport","Preflight","Other".' }),
|
|
73
|
+
),
|
|
74
|
+
sinceMs: Type.Optional(
|
|
75
|
+
Type.Integer({ minimum: 0, description: "Only requests started in the last N ms." }),
|
|
76
|
+
),
|
|
77
|
+
limit: Type.Optional(
|
|
78
|
+
Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
|
|
79
|
+
),
|
|
80
|
+
includeResponseBodies: Type.Optional(
|
|
81
|
+
Type.Boolean({ default: false, description: "Fetch response bodies via Network.getResponseBody. Costs one CDP call per matched request." }),
|
|
55
82
|
),
|
|
56
|
-
limit: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 500 })),
|
|
57
83
|
});
|
|
58
84
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
85
|
+
const BODY_BUDGET_MS = 5_000;
|
|
86
|
+
const PER_BODY_CAP = 50_000;
|
|
87
|
+
|
|
88
|
+
const renderNetworkMarkdown = (
|
|
89
|
+
records: ReadonlyArray<NetworkRecord>,
|
|
90
|
+
opts: { includeBodies: boolean; total: number; bufferOverflowed: boolean; expanded: boolean },
|
|
91
|
+
): string => {
|
|
92
|
+
if (records.length === 0) {
|
|
93
|
+
if (opts.bufferOverflowed) return "_No matching requests. (Buffer overflowed since last drain — some events were dropped.)_";
|
|
94
|
+
return "_No matching requests captured yet._";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const failed = records.filter((r) => r.failed || (r.status !== undefined && r.status >= 500)).length;
|
|
98
|
+
const totalBytes = records.reduce((s, r) => s + (r.responseBodySize ?? 0), 0);
|
|
99
|
+
const formatBytes = (n: number): string => {
|
|
100
|
+
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
|
|
101
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
102
|
+
return `${n} B`;
|
|
103
|
+
};
|
|
104
|
+
const showWindow = (() => {
|
|
105
|
+
const finished = records.filter((r) => r.durationMs !== undefined);
|
|
106
|
+
if (finished.length === 0) return "";
|
|
107
|
+
const span = Math.max(...records.map((r) => r.requestStartedMs)) - Math.min(...records.map((r) => r.requestStartedMs));
|
|
108
|
+
return ` · ${(span / 1000).toFixed(1)}s window`;
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
const headerLines: string[] = [
|
|
112
|
+
`**${records.length} requests** · ${failed} failed · ${formatBytes(totalBytes)}${showWindow}`,
|
|
113
|
+
];
|
|
114
|
+
if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older events dropped");
|
|
115
|
+
if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
|
|
116
|
+
|
|
117
|
+
const compactRows = (records.length > 5 && !opts.expanded ? records.slice(0, 5) : records).map((r) => {
|
|
118
|
+
const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
|
|
119
|
+
const warn = r.failed || (r.status !== undefined && r.status >= 500) ? " ⚠" : "";
|
|
120
|
+
const ms = r.durationMs !== undefined ? `${r.durationMs} ms` : "—";
|
|
121
|
+
const url = r.url.length > 60 ? r.url.slice(0, 57) + "…" : r.url;
|
|
122
|
+
return `\`${r.method.padEnd(4)} ${status.padStart(3)}\` ${url} _${ms}_${warn}`;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const rows = compactRows.join("\n");
|
|
126
|
+
|
|
127
|
+
if (!opts.expanded) {
|
|
128
|
+
const more = records.length > 5 ? `\n … ${records.length - 5} more` : "";
|
|
129
|
+
return `${headerLines.join("\n")}\n\n${rows}${more}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Expanded: full table + optional body sections.
|
|
133
|
+
const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`;
|
|
134
|
+
const tableRows = records
|
|
135
|
+
.map((r, i) => {
|
|
136
|
+
const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
|
|
137
|
+
const ms = r.durationMs !== undefined ? String(r.durationMs) : "—";
|
|
138
|
+
const url = r.url.length > 80 ? r.url.slice(0, 77) + "…" : r.url;
|
|
139
|
+
return `| ${i + 1} | ${r.method} | ${status} | \`${url}\` | ${r.type} | ${ms} |`;
|
|
140
|
+
})
|
|
141
|
+
.join("\n");
|
|
142
|
+
|
|
143
|
+
let bodySections = "";
|
|
144
|
+
if (opts.includeBodies) {
|
|
145
|
+
const sections: string[] = [];
|
|
146
|
+
records.forEach((r, i) => {
|
|
147
|
+
if (r.body === undefined) return;
|
|
148
|
+
const sizes = `${r.requestBodySize} B → ${r.responseBodySize ?? 0} B`;
|
|
149
|
+
const status = r.failed ? "failed" : r.status ?? "?";
|
|
150
|
+
const header = `── #${i + 1} ${r.method} ${r.url} (${status}, ${r.durationMs ?? "?"} ms, ${r.type}, ${sizes}) ──`;
|
|
151
|
+
const bodyText = r.body === null ? "_(body unavailable — already GC'd by Chrome)_" : "```\n" + r.body + "\n```";
|
|
152
|
+
sections.push(`${header}\n\n${bodyText}`);
|
|
153
|
+
});
|
|
154
|
+
if (sections.length > 0) bodySections = "\n\n" + sections.join("\n\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return [headerLines.join("\n"), "", tableHeader, tableRows, bodySections].filter((s) => s !== "").join("\n");
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type NetworkDetails = {
|
|
161
|
+
total: number;
|
|
162
|
+
returned: number;
|
|
163
|
+
bufferOverflowed: boolean;
|
|
164
|
+
truncated: boolean;
|
|
165
|
+
fullOutputPath?: string;
|
|
166
|
+
requests: ReadonlyArray<NetworkRecord>;
|
|
167
|
+
includeBodies: boolean;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const networkRequestsTool = defineBrowserTool({
|
|
171
|
+
name: "browser_network_requests",
|
|
172
|
+
label: "Browser Network Requests",
|
|
62
173
|
description:
|
|
63
|
-
"
|
|
64
|
-
promptSnippet: "
|
|
174
|
+
"List recent network requests captured on the current tab since attach. Filter by URL pattern, method, status, resource type, or recency. Optionally include response bodies.",
|
|
175
|
+
promptSnippet: "Inspect recent network requests on the current page",
|
|
65
176
|
promptGuidelines: [
|
|
66
|
-
"
|
|
67
|
-
|
|
177
|
+
"Buffer is reset on tab switch — only requests on the current tab since attach are visible.",
|
|
178
|
+
'urlPattern is a substring; wrap in slashes for regex (e.g. "/\\\\.json$/").',
|
|
179
|
+
"includeResponseBodies:true costs an extra CDP call per matched request — use only when you need payloads.",
|
|
180
|
+
"If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
|
|
68
181
|
],
|
|
69
|
-
parameters:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
},
|
|
182
|
+
parameters: NetworkRequestsArgs,
|
|
183
|
+
|
|
184
|
+
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
185
|
+
const session = client.session();
|
|
186
|
+
const drained = session.drainNetworkBuffer({
|
|
187
|
+
...(args.urlPattern !== undefined ? { urlPattern: args.urlPattern } : {}),
|
|
188
|
+
...(args.methodFilter !== undefined ? { methodFilter: args.methodFilter } : {}),
|
|
189
|
+
...(args.statusFilter !== undefined ? { statusFilter: args.statusFilter } : {}),
|
|
190
|
+
...(args.resourceTypes !== undefined ? { resourceTypes: args.resourceTypes } : {}),
|
|
191
|
+
...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
|
|
192
|
+
limit: args.limit ?? 50,
|
|
78
193
|
});
|
|
194
|
+
|
|
195
|
+
if (args.includeResponseBodies) {
|
|
196
|
+
const ac = new AbortController();
|
|
197
|
+
const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS);
|
|
198
|
+
const fills = drained.records.map(async (r) => {
|
|
199
|
+
if (ac.signal.aborted) return;
|
|
200
|
+
const bodyRes = await session.call("Network.getResponseBody", { requestId: r.requestId });
|
|
201
|
+
if (!bodyRes.success) {
|
|
202
|
+
r.body = null;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const b = bodyRes.data as { body: string; base64Encoded?: boolean };
|
|
206
|
+
let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
|
|
207
|
+
if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
|
|
208
|
+
r.body = body;
|
|
209
|
+
});
|
|
210
|
+
await Promise.allSettled(fills);
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const text = renderNetworkMarkdown(drained.records, {
|
|
215
|
+
includeBodies: args.includeResponseBodies ?? false,
|
|
216
|
+
total: drained.total,
|
|
217
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
218
|
+
expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
|
|
219
|
+
});
|
|
220
|
+
const trunc = await applyTruncation(text, "network");
|
|
221
|
+
|
|
222
|
+
const details: NetworkDetails = {
|
|
223
|
+
total: drained.total,
|
|
224
|
+
returned: drained.records.length,
|
|
225
|
+
bufferOverflowed: drained.bufferOverflowed,
|
|
226
|
+
truncated: trunc.wasTruncated,
|
|
227
|
+
requests: drained.records,
|
|
228
|
+
includeBodies: args.includeResponseBodies ?? false,
|
|
229
|
+
};
|
|
230
|
+
if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
|
|
231
|
+
|
|
232
|
+
return ok({ text: trunc.text, details });
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
renderResult(result, expanded, theme) {
|
|
236
|
+
const details = result.details as NetworkDetails | undefined;
|
|
237
|
+
if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
|
|
238
|
+
|
|
239
|
+
const md = renderNetworkMarkdown(details.requests, {
|
|
240
|
+
includeBodies: details.includeBodies && expanded,
|
|
241
|
+
total: details.total,
|
|
242
|
+
bufferOverflowed: details.bufferOverflowed,
|
|
243
|
+
expanded,
|
|
244
|
+
});
|
|
245
|
+
const tail = expanded
|
|
246
|
+
? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
|
|
247
|
+
: `\n\n${keyHint("app.tools.expand", "to expand")}`;
|
|
248
|
+
|
|
249
|
+
return new Markdown(md + tail, 0, 0, getMarkdownTheme());
|
|
79
250
|
},
|
|
80
251
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import { writeFile, rename } from "node:fs/promises";
|
|
2
3
|
import { Type } from "typebox";
|
|
4
|
+
import { Image, type ImageTheme, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
3
5
|
import type { BrowserClient } from "../client";
|
|
4
6
|
import { type Result, err, ok } from "../util/result";
|
|
5
7
|
import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
|
|
@@ -61,12 +63,15 @@ const resizeIfNeeded = async (
|
|
|
61
63
|
export const screenshotTool = defineBrowserTool({
|
|
62
64
|
name: "browser_screenshot",
|
|
63
65
|
label: "Browser Screenshot",
|
|
64
|
-
description:
|
|
65
|
-
|
|
66
|
+
description:
|
|
67
|
+
"Capture the page as a JPEG/PNG image. NOT a default exploration tool. Use ONLY when (a) you need to verify visual properties (layout, color, spacing, rendered text legibility), or (b) browser_snapshot and browser_execute_js cannot answer your question. For understanding what's on the page use browser_snapshot. For specific element values use browser_execute_js. For click coordinates use the @(x,y) hints in browser_snapshot's outline.",
|
|
68
|
+
promptSnippet: "Capture a screenshot — visual verification only",
|
|
66
69
|
promptGuidelines: [
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
+
"DO NOT use as a default exploration tool. browser_snapshot is the default for understanding page structure — far cheaper and more reliable.",
|
|
71
|
+
"DO NOT use to find click coordinates. browser_snapshot already includes @(x,y) for every interactive element. If a target isn't there, use browser_execute_js with element.getBoundingClientRect().",
|
|
72
|
+
"DO use to verify visual rendering after an action: did the modal animate in correctly, did the chart render, are colors right, did the layout reflow as expected.",
|
|
73
|
+
"DO use as a debugging fallback when browser_execute_js or browser_snapshot return surprising results and you suspect the rendered page differs from the DOM.",
|
|
74
|
+
"format='jpeg' with quality 60-90 keeps screenshots small for photo-heavy pages. Use maxDim if the page is huge.",
|
|
70
75
|
],
|
|
71
76
|
parameters: ScreenshotArgs,
|
|
72
77
|
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
|
|
@@ -82,6 +87,40 @@ export const screenshotTool = defineBrowserTool({
|
|
|
82
87
|
details: { path: cap.data.path, format: cap.data.format, attached: false },
|
|
83
88
|
});
|
|
84
89
|
},
|
|
90
|
+
|
|
91
|
+
renderResult(result, _expanded, theme) {
|
|
92
|
+
const details = result.details as { path?: string; format?: string } | undefined;
|
|
93
|
+
const filePath = details?.path;
|
|
94
|
+
if (!filePath) {
|
|
95
|
+
return new Text(theme.fg("error", "Screenshot path missing"), 0, 0);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const buf = readFileSync(filePath);
|
|
99
|
+
const b64 = buf.toString("base64");
|
|
100
|
+
const mimeType = details?.format === "jpeg" ? "image/jpeg" : "image/png";
|
|
101
|
+
const imageTheme: ImageTheme = {
|
|
102
|
+
fallbackColor: (str: string) => theme.fg("dim", str),
|
|
103
|
+
};
|
|
104
|
+
const image = new Image(b64, mimeType, imageTheme, {
|
|
105
|
+
maxWidthCells: 80,
|
|
106
|
+
maxHeightCells: 24,
|
|
107
|
+
filename: filePath,
|
|
108
|
+
});
|
|
109
|
+
// Image's text fallback (when terminal lacks inline-image support) does
|
|
110
|
+
// not respect the rendered width, so a long file path can blow past the
|
|
111
|
+
// terminal width and crash the host TUI. Wrap the component to truncate
|
|
112
|
+
// each rendered line to fit.
|
|
113
|
+
return {
|
|
114
|
+
invalidate: () => image.invalidate(),
|
|
115
|
+
render: (width: number) =>
|
|
116
|
+
image.render(width).map((line) =>
|
|
117
|
+
visibleWidth(line) > width ? truncateToWidth(line, width) : line,
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return new Text(theme.fg("warning", `Screenshot saved: ${filePath}`), 0, 0);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
85
124
|
});
|
|
86
125
|
|
|
87
126
|
export const captureWithCrosshair = async (
|
package/src/domains/scroll.ts
CHANGED
|
@@ -7,8 +7,8 @@ const ScrollArgs = Type.Object({
|
|
|
7
7
|
y: Type.Optional(Type.Number({ description: "Y coordinate where to scroll. Default: viewport center" })),
|
|
8
8
|
deltaX: Type.Optional(Type.Number({ default: 0, description: "Horizontal scroll delta (CSS pixels). Positive = right." })),
|
|
9
9
|
deltaY: Type.Optional(Type.Number({
|
|
10
|
-
default:
|
|
11
|
-
description: "Vertical scroll delta (CSS pixels). Positive = up (
|
|
10
|
+
default: 300,
|
|
11
|
+
description: "Vertical scroll delta (CSS pixels). Positive = down, negative = up (W3C wheel-event convention). Default: 300 (scroll down).",
|
|
12
12
|
})),
|
|
13
13
|
});
|
|
14
14
|
|
|
@@ -16,10 +16,10 @@ export const scrollTool = defineBrowserTool({
|
|
|
16
16
|
name: "browser_scroll",
|
|
17
17
|
label: "Browser Scroll",
|
|
18
18
|
description:
|
|
19
|
-
"Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll
|
|
20
|
-
promptSnippet: "Scroll the page (deltaY positive=
|
|
19
|
+
"Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll down, negative = scroll up. Default scrolls down (deltaY = 300).",
|
|
20
|
+
promptSnippet: "Scroll the page (deltaY positive=down, negative=up)",
|
|
21
21
|
promptGuidelines: [
|
|
22
|
-
"Default behavior scrolls down 300px (deltaY
|
|
22
|
+
"Default behavior scrolls down 300px (deltaY=300). Pass a negative deltaY to scroll up.",
|
|
23
23
|
"Pass x/y to target a specific scrollable region (e.g., a div with overflow); otherwise scrolls the page at viewport center.",
|
|
24
24
|
],
|
|
25
25
|
parameters: ScrollArgs,
|
|
@@ -35,14 +35,21 @@ export const scrollTool = defineBrowserTool({
|
|
|
35
35
|
const cx = args.x ?? Math.round(info.data.width / 2);
|
|
36
36
|
const cy = args.y ?? Math.round(info.data.height / 2);
|
|
37
37
|
const dx = args.deltaX ?? 0;
|
|
38
|
-
const dy = args.deltaY ??
|
|
38
|
+
const dy = args.deltaY ?? 300;
|
|
39
|
+
// The page target must be focused or Input events are dropped silently
|
|
40
|
+
// (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll).
|
|
41
|
+
// Page.bringToFront makes the target the active one in the browser.
|
|
42
|
+
const front = await client.session().call("Page.bringToFront", {});
|
|
43
|
+
if (!front.success) return err({ kind: "cdp_error", message: front.error.message });
|
|
39
44
|
// Establish compositor mouse position before mouseWheel; without this,
|
|
40
|
-
// CDP sometimes drops the wheel event with a timeout.
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
// CDP sometimes drops the wheel event with a timeout.
|
|
46
|
+
const moved = await client.session().call("Input.dispatchMouseEvent", {
|
|
47
|
+
type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0,
|
|
48
|
+
});
|
|
43
49
|
if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message });
|
|
44
50
|
const wheel = await client.session().call("Input.dispatchMouseEvent", {
|
|
45
51
|
type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy,
|
|
52
|
+
button: "none", buttons: 0, pointerType: "mouse",
|
|
46
53
|
});
|
|
47
54
|
if (!wheel.success) return err({ kind: "cdp_error", message: wheel.error.message });
|
|
48
55
|
return ok({
|