pi-browser-harness 0.12.0 → 0.14.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 +56 -0
- package/README.md +132 -0
- package/package.json +15 -7
- package/skills/pi-browser-harness/SKILL.md +89 -2
- package/src/cdp/commands.ts +205 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +57 -25
- package/src/cdp/events.ts +9 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +358 -216
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +127 -54
- package/src/daemon/bridge.ts +110 -349
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +21 -22
- package/src/daemon/transport.ts +80 -49
- package/src/domains/ax-tree.ts +27 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -2
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -64
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +13 -9
- package/src/domains/form-batch.ts +13 -10
- package/src/domains/form.ts +78 -107
- package/src/domains/headers.ts +2 -2
- package/src/domains/history.ts +4 -4
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +67 -46
- package/src/domains/keyboard.ts +14 -11
- package/src/domains/navigate.ts +102 -66
- package/src/domains/network.ts +55 -95
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/recipe.page.ts +82 -0
- package/src/domains/page/serp.page.ts +42 -0
- package/src/domains/page.ts +18 -51
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -53
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -10
- package/src/domains/scroll.ts +4 -4
- package/src/domains/search/google-serp.ts +78 -8
- package/src/domains/search/redirect-resolver.ts +41 -0
- package/src/domains/search/web-search.ts +103 -40
- package/src/domains/snapshot.ts +81 -78
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +18 -69
- package/src/domains/target.ts +1 -1
- package/src/index.ts +101 -33
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +34 -8
- package/src/recipes/command.ts +96 -0
- package/src/recipes/compile.ts +198 -0
- package/src/recipes/find.ts +49 -0
- package/src/recipes/journal.ts +125 -0
- package/src/recipes/recipe.ts +215 -0
- package/src/recipes/recordable.ts +359 -0
- package/src/recipes/replay.ts +354 -0
- package/src/recipes/run-tool.ts +132 -0
- package/src/recipes/save-tool.ts +247 -0
- package/src/recipes/store.ts +103 -0
- package/src/recipes/trust.ts +172 -0
- package/src/registry.ts +69 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +2 -1
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/js-source.ts +28 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +3 -2
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +185 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- package/src/domains/fill-engine.ts +0 -80
package/src/cdp/commands.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
// The typed CDP command table — the extension point for everything the harness sends to Chrome.
|
|
2
|
+
//
|
|
3
|
+
// To add a command:
|
|
4
|
+
// 1. Look the method up in the Chrome DevTools Protocol viewer (https://chromedevtools.github.io/devtools-protocol/).
|
|
5
|
+
// 2. Add a `"Domain.method": cmd(ParamsSchema, ResultSchema)` entry below, in its domain's block.
|
|
6
|
+
// - Mark optional protocol fields with `Type.Optional(...)` — never with `Type.Union([..., Type.Undefined()])`:
|
|
7
|
+
// the project compiles with exactOptionalPropertyTypes, so callers must omit a field rather than pass undefined.
|
|
8
|
+
// - Use `Type.Union([Type.Literal(...), ...])` for enumerated strings so a typo fails to compile.
|
|
9
|
+
// - Result schemas are `additionalProperties: true` where Chrome may add fields; param schemas are closed.
|
|
10
|
+
// 3. `ParamsOf<"Domain.method">` / `ResultOf<"Domain.method">` now exist, and `session.call("Domain.method", ...)`
|
|
11
|
+
// requires params exactly when the schema has a required key (see `CallArgs` in session.ts).
|
|
12
|
+
// 4. Results are validated at runtime by `decodeResult`; a payload that does not match is an `invalid_response` error.
|
|
13
|
+
// Events live in ./events.ts and follow the same pattern.
|
|
14
|
+
|
|
1
15
|
import { Type, type Static, type TSchema } from "typebox";
|
|
2
16
|
import { Compile } from "typebox/compile";
|
|
3
17
|
import { type Result, err, ok } from "../util/result";
|
|
@@ -77,6 +91,82 @@ const AxNode = Type.Object(
|
|
|
77
91
|
|
|
78
92
|
export type AxNodeResult = Static<typeof AxNode>;
|
|
79
93
|
|
|
94
|
+
// Page.Viewport: a clip rectangle in CSS pixels.
|
|
95
|
+
const Viewport = Type.Object({
|
|
96
|
+
x: Type.Number(),
|
|
97
|
+
y: Type.Number(),
|
|
98
|
+
width: Type.Number(),
|
|
99
|
+
height: Type.Number(),
|
|
100
|
+
scale: Type.Number(),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Browser.setDownloadBehavior — "allowAndName" saves under the download's GUID; "default" restores Chrome's own policy.
|
|
104
|
+
const DownloadBehavior = Type.Union([
|
|
105
|
+
Type.Literal("deny"),
|
|
106
|
+
Type.Literal("allow"),
|
|
107
|
+
Type.Literal("allowAndName"),
|
|
108
|
+
Type.Literal("default"),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const ScreenshotFormat = Type.Union([Type.Literal("jpeg"), Type.Literal("png"), Type.Literal("webp")]);
|
|
112
|
+
|
|
113
|
+
// Input.MouseButton
|
|
114
|
+
const MouseButton = Type.Union([
|
|
115
|
+
Type.Literal("none"),
|
|
116
|
+
Type.Literal("left"),
|
|
117
|
+
Type.Literal("middle"),
|
|
118
|
+
Type.Literal("right"),
|
|
119
|
+
Type.Literal("back"),
|
|
120
|
+
Type.Literal("forward"),
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const MouseEventType = Type.Union([
|
|
124
|
+
Type.Literal("mousePressed"),
|
|
125
|
+
Type.Literal("mouseReleased"),
|
|
126
|
+
Type.Literal("mouseMoved"),
|
|
127
|
+
Type.Literal("mouseWheel"),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const KeyEventType = Type.Union([
|
|
131
|
+
Type.Literal("keyDown"),
|
|
132
|
+
Type.Literal("keyUp"),
|
|
133
|
+
Type.Literal("rawKeyDown"),
|
|
134
|
+
Type.Literal("char"),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const DragEventType = Type.Union([
|
|
138
|
+
Type.Literal("dragEnter"),
|
|
139
|
+
Type.Literal("dragOver"),
|
|
140
|
+
Type.Literal("drop"),
|
|
141
|
+
Type.Literal("dragCancel"),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// Input.DragDataItem / Input.DragData. `data` is the payload as a string (binary payloads are base64).
|
|
145
|
+
const DragDataItem = Type.Object({
|
|
146
|
+
mimeType: Type.String(),
|
|
147
|
+
data: Type.String(),
|
|
148
|
+
title: Type.Optional(Type.String()),
|
|
149
|
+
baseURL: Type.Optional(Type.String()),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const DragData = Type.Object({
|
|
153
|
+
items: Type.Array(DragDataItem),
|
|
154
|
+
files: Type.Optional(Type.Array(Type.String())),
|
|
155
|
+
// Bit field of allowed operations: 1=Copy, 2=Link, 16=Move.
|
|
156
|
+
dragOperationsMask: Type.Integer(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Emulation.ScreenOrientation
|
|
160
|
+
const ScreenOrientation = Type.Object({
|
|
161
|
+
type: Type.Union([
|
|
162
|
+
Type.Literal("portraitPrimary"),
|
|
163
|
+
Type.Literal("portraitSecondary"),
|
|
164
|
+
Type.Literal("landscapePrimary"),
|
|
165
|
+
Type.Literal("landscapeSecondary"),
|
|
166
|
+
]),
|
|
167
|
+
angle: Type.Integer(),
|
|
168
|
+
});
|
|
169
|
+
|
|
80
170
|
export const COMMANDS = {
|
|
81
171
|
"Target.attachToTarget": cmd(
|
|
82
172
|
Type.Object({ targetId: Type.String(), flatten: Type.Optional(Type.Boolean()) }),
|
|
@@ -117,7 +207,8 @@ export const COMMANDS = {
|
|
|
117
207
|
),
|
|
118
208
|
"Browser.setDownloadBehavior": cmd(
|
|
119
209
|
Type.Object({
|
|
120
|
-
behavior:
|
|
210
|
+
behavior: DownloadBehavior,
|
|
211
|
+
browserContextId: Type.Optional(Type.String()),
|
|
121
212
|
downloadPath: Type.Optional(Type.String()),
|
|
122
213
|
eventsEnabled: Type.Optional(Type.Boolean()),
|
|
123
214
|
}),
|
|
@@ -147,14 +238,39 @@ export const COMMANDS = {
|
|
|
147
238
|
"Page.navigateToHistoryEntry": cmd(Type.Object({ entryId: Type.Number() }), Empty),
|
|
148
239
|
"Page.captureScreenshot": cmd(
|
|
149
240
|
Type.Object({
|
|
150
|
-
format: Type.Optional(
|
|
151
|
-
|
|
152
|
-
|
|
241
|
+
format: Type.Optional(ScreenshotFormat),
|
|
242
|
+
// 0–100, jpeg and webp only.
|
|
243
|
+
quality: Type.Optional(Type.Integer()),
|
|
244
|
+
clip: Type.Optional(Viewport),
|
|
245
|
+
fromSurface: Type.Optional(Type.Boolean()),
|
|
153
246
|
captureBeyondViewport: Type.Optional(Type.Boolean()),
|
|
247
|
+
optimizeForSpeed: Type.Optional(Type.Boolean()),
|
|
154
248
|
}),
|
|
155
249
|
Type.Object({ data: Type.String() }),
|
|
156
250
|
),
|
|
157
|
-
"Page.printToPDF": cmd(
|
|
251
|
+
"Page.printToPDF": cmd(
|
|
252
|
+
Type.Object({
|
|
253
|
+
landscape: Type.Optional(Type.Boolean()),
|
|
254
|
+
displayHeaderFooter: Type.Optional(Type.Boolean()),
|
|
255
|
+
printBackground: Type.Optional(Type.Boolean()),
|
|
256
|
+
scale: Type.Optional(Type.Number()),
|
|
257
|
+
// Paper size and margins are in inches.
|
|
258
|
+
paperWidth: Type.Optional(Type.Number()),
|
|
259
|
+
paperHeight: Type.Optional(Type.Number()),
|
|
260
|
+
marginTop: Type.Optional(Type.Number()),
|
|
261
|
+
marginBottom: Type.Optional(Type.Number()),
|
|
262
|
+
marginLeft: Type.Optional(Type.Number()),
|
|
263
|
+
marginRight: Type.Optional(Type.Number()),
|
|
264
|
+
pageRanges: Type.Optional(Type.String()),
|
|
265
|
+
headerTemplate: Type.Optional(Type.String()),
|
|
266
|
+
footerTemplate: Type.Optional(Type.String()),
|
|
267
|
+
preferCSSPageSize: Type.Optional(Type.Boolean()),
|
|
268
|
+
transferMode: Type.Optional(Type.Union([Type.Literal("ReturnAsBase64"), Type.Literal("ReturnAsStream")])),
|
|
269
|
+
generateTaggedPDF: Type.Optional(Type.Boolean()),
|
|
270
|
+
generateDocumentOutline: Type.Optional(Type.Boolean()),
|
|
271
|
+
}),
|
|
272
|
+
Type.Object({ data: Type.String(), stream: Type.Optional(Type.String()) }),
|
|
273
|
+
),
|
|
158
274
|
"Page.handleJavaScriptDialog": cmd(
|
|
159
275
|
Type.Object({ accept: Type.Boolean(), promptText: Type.Optional(Type.String()) }),
|
|
160
276
|
Empty,
|
|
@@ -167,6 +283,7 @@ export const COMMANDS = {
|
|
|
167
283
|
returnByValue: Type.Optional(Type.Boolean()),
|
|
168
284
|
awaitPromise: Type.Optional(Type.Boolean()),
|
|
169
285
|
userGesture: Type.Optional(Type.Boolean()),
|
|
286
|
+
replMode: Type.Optional(Type.Boolean()),
|
|
170
287
|
},
|
|
171
288
|
{ additionalProperties: true },
|
|
172
289
|
),
|
|
@@ -209,6 +326,10 @@ export const COMMANDS = {
|
|
|
209
326
|
Type.Object({ nodeId: Type.Optional(Type.Number()), backendNodeId: Type.Optional(Type.Number()) }),
|
|
210
327
|
Type.Object({ object: RemoteObject }),
|
|
211
328
|
),
|
|
329
|
+
"DOM.describeNode": cmd(
|
|
330
|
+
Type.Object({ objectId: Type.String() }),
|
|
331
|
+
Type.Object({ node: Type.Object({ backendNodeId: Type.Number() }, { additionalProperties: true }) }),
|
|
332
|
+
),
|
|
212
333
|
"DOM.setFileInputFiles": cmd(
|
|
213
334
|
Type.Object({
|
|
214
335
|
files: Type.Array(Type.String()),
|
|
@@ -219,9 +340,60 @@ export const COMMANDS = {
|
|
|
219
340
|
Empty,
|
|
220
341
|
),
|
|
221
342
|
|
|
222
|
-
"Input.dispatchMouseEvent": cmd(
|
|
223
|
-
|
|
224
|
-
|
|
343
|
+
"Input.dispatchMouseEvent": cmd(
|
|
344
|
+
Type.Object({
|
|
345
|
+
type: MouseEventType,
|
|
346
|
+
x: Type.Number(),
|
|
347
|
+
y: Type.Number(),
|
|
348
|
+
// Bit field: 1=Alt, 2=Ctrl, 4=Meta/Command, 8=Shift.
|
|
349
|
+
modifiers: Type.Optional(Type.Integer()),
|
|
350
|
+
timestamp: Type.Optional(Type.Number()),
|
|
351
|
+
button: Type.Optional(MouseButton),
|
|
352
|
+
// Bit field of pressed buttons: 1=Left, 2=Right, 4=Middle, 8=Back, 16=Forward.
|
|
353
|
+
buttons: Type.Optional(Type.Integer()),
|
|
354
|
+
clickCount: Type.Optional(Type.Integer()),
|
|
355
|
+
force: Type.Optional(Type.Number()),
|
|
356
|
+
tangentialPressure: Type.Optional(Type.Number()),
|
|
357
|
+
tiltX: Type.Optional(Type.Number()),
|
|
358
|
+
tiltY: Type.Optional(Type.Number()),
|
|
359
|
+
twist: Type.Optional(Type.Integer()),
|
|
360
|
+
deltaX: Type.Optional(Type.Number()),
|
|
361
|
+
deltaY: Type.Optional(Type.Number()),
|
|
362
|
+
pointerType: Type.Optional(Type.Union([Type.Literal("mouse"), Type.Literal("pen")])),
|
|
363
|
+
}),
|
|
364
|
+
Empty,
|
|
365
|
+
),
|
|
366
|
+
"Input.dispatchKeyEvent": cmd(
|
|
367
|
+
Type.Object({
|
|
368
|
+
type: KeyEventType,
|
|
369
|
+
modifiers: Type.Optional(Type.Integer()),
|
|
370
|
+
timestamp: Type.Optional(Type.Number()),
|
|
371
|
+
text: Type.Optional(Type.String()),
|
|
372
|
+
unmodifiedText: Type.Optional(Type.String()),
|
|
373
|
+
keyIdentifier: Type.Optional(Type.String()),
|
|
374
|
+
code: Type.Optional(Type.String()),
|
|
375
|
+
key: Type.Optional(Type.String()),
|
|
376
|
+
windowsVirtualKeyCode: Type.Optional(Type.Integer()),
|
|
377
|
+
nativeVirtualKeyCode: Type.Optional(Type.Integer()),
|
|
378
|
+
autoRepeat: Type.Optional(Type.Boolean()),
|
|
379
|
+
isKeypad: Type.Optional(Type.Boolean()),
|
|
380
|
+
isSystemKey: Type.Optional(Type.Boolean()),
|
|
381
|
+
// 0=Standard, 1=Left, 2=Right, 3=Numpad.
|
|
382
|
+
location: Type.Optional(Type.Integer()),
|
|
383
|
+
commands: Type.Optional(Type.Array(Type.String())),
|
|
384
|
+
}),
|
|
385
|
+
Empty,
|
|
386
|
+
),
|
|
387
|
+
"Input.dispatchDragEvent": cmd(
|
|
388
|
+
Type.Object({
|
|
389
|
+
type: DragEventType,
|
|
390
|
+
x: Type.Number(),
|
|
391
|
+
y: Type.Number(),
|
|
392
|
+
data: DragData,
|
|
393
|
+
modifiers: Type.Optional(Type.Integer()),
|
|
394
|
+
}),
|
|
395
|
+
Empty,
|
|
396
|
+
),
|
|
225
397
|
"Input.insertText": cmd(Type.Object({ text: Type.String() }), Empty),
|
|
226
398
|
|
|
227
399
|
"Network.getResponseBody": cmd(
|
|
@@ -232,9 +404,27 @@ export const COMMANDS = {
|
|
|
232
404
|
Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
|
|
233
405
|
Empty,
|
|
234
406
|
),
|
|
235
|
-
"Emulation.setDeviceMetricsOverride": cmd(
|
|
407
|
+
"Emulation.setDeviceMetricsOverride": cmd(
|
|
408
|
+
Type.Object({
|
|
409
|
+
// 0 disables the override for that dimension.
|
|
410
|
+
width: Type.Integer(),
|
|
411
|
+
height: Type.Integer(),
|
|
412
|
+
// 0 disables the override.
|
|
413
|
+
deviceScaleFactor: Type.Number(),
|
|
414
|
+
mobile: Type.Boolean(),
|
|
415
|
+
scale: Type.Optional(Type.Number()),
|
|
416
|
+
screenWidth: Type.Optional(Type.Integer()),
|
|
417
|
+
screenHeight: Type.Optional(Type.Integer()),
|
|
418
|
+
positionX: Type.Optional(Type.Integer()),
|
|
419
|
+
positionY: Type.Optional(Type.Integer()),
|
|
420
|
+
dontSetVisibleSize: Type.Optional(Type.Boolean()),
|
|
421
|
+
screenOrientation: Type.Optional(ScreenOrientation),
|
|
422
|
+
viewport: Type.Optional(Viewport),
|
|
423
|
+
}),
|
|
424
|
+
Empty,
|
|
425
|
+
),
|
|
236
426
|
"Accessibility.getFullAXTree": cmd(
|
|
237
|
-
Type.Object({ depth: Type.Optional(Type.Number())
|
|
427
|
+
Type.Object({ depth: Type.Optional(Type.Number()), frameId: Type.Optional(Type.String()) }),
|
|
238
428
|
Type.Object({ nodes: Type.Array(AxNode) }),
|
|
239
429
|
),
|
|
240
430
|
};
|
|
@@ -247,14 +437,15 @@ export const decodeResult = <M extends CdpMethod>(
|
|
|
247
437
|
method: M,
|
|
248
438
|
raw: unknown,
|
|
249
439
|
): Result<ResultOf<M>, CdpError> => {
|
|
440
|
+
// `method` is a key of COMMANDS, so the lookup cannot miss.
|
|
250
441
|
const spec = COMMANDS[method];
|
|
251
|
-
if (spec === undefined) {
|
|
252
|
-
return err(cdpError("invalid_response", `unknown CDP method`, method));
|
|
253
|
-
}
|
|
254
442
|
if (!spec.validate.Check(raw)) {
|
|
255
443
|
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
256
444
|
return err(cdpError("invalid_response", why, method));
|
|
257
445
|
}
|
|
258
|
-
// Guarded assertion: the check above proves `raw` matches COMMANDS[method].result
|
|
446
|
+
// Guarded assertion: the check above proves `raw` matches COMMANDS[method].result. TS resolves
|
|
447
|
+
// `spec.validate` through a generic key to the union of every validator, so the predicate
|
|
448
|
+
// narrows `raw` to the union of every result type rather than to ResultOf<M>.
|
|
449
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
|
|
259
450
|
return ok(raw as ResultOf<M>);
|
|
260
451
|
};
|
|
@@ -1,32 +1,39 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
1
2
|
import { decodeEvent } from "./events";
|
|
2
|
-
import { createRecordStore } from "./record-store";
|
|
3
|
+
import { createRecordStore, type DrainPage } from "./record-store";
|
|
3
4
|
|
|
4
5
|
export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
// The one declaration of a console record: the buffer produces it and browser_console validates replayed
|
|
8
|
+
// details against it, so the two cannot drift.
|
|
9
|
+
export const ConsoleRecordSchema = Type.Object({
|
|
10
|
+
seq: Type.Number(),
|
|
11
|
+
timestampMs: Type.Number(),
|
|
12
|
+
level: Type.Union([
|
|
13
|
+
Type.Literal("log"),
|
|
14
|
+
Type.Literal("info"),
|
|
15
|
+
Type.Literal("warn"),
|
|
16
|
+
Type.Literal("error"),
|
|
17
|
+
Type.Literal("debug"),
|
|
18
|
+
]),
|
|
19
|
+
text: Type.String(),
|
|
20
|
+
source: Type.Union([Type.Literal("console-api"), Type.Literal("log-entry")]),
|
|
21
|
+
url: Type.Optional(Type.String()),
|
|
22
|
+
lineNumber: Type.Optional(Type.Number()),
|
|
23
|
+
stackTrace: Type.Optional(Type.String()),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type ConsoleRecord = Readonly<Static<typeof ConsoleRecordSchema>>;
|
|
16
27
|
|
|
17
28
|
export type ConsoleFilter = {
|
|
18
|
-
levels?: ConsoleLevel
|
|
19
|
-
textPattern?: string;
|
|
20
|
-
sinceSeq?: number;
|
|
21
|
-
sinceMs?: number;
|
|
22
|
-
limit?: number;
|
|
29
|
+
readonly levels?: ReadonlyArray<ConsoleLevel>;
|
|
30
|
+
readonly textPattern?: string;
|
|
31
|
+
readonly sinceSeq?: number;
|
|
32
|
+
readonly sinceMs?: number;
|
|
33
|
+
readonly limit?: number;
|
|
23
34
|
};
|
|
24
35
|
|
|
25
|
-
export type ConsoleDrainResult =
|
|
26
|
-
readonly records: ConsoleRecord[];
|
|
27
|
-
readonly total: number;
|
|
28
|
-
readonly bufferOverflowed: boolean;
|
|
29
|
-
};
|
|
36
|
+
export type ConsoleDrainResult = DrainPage<ConsoleRecord>;
|
|
30
37
|
|
|
31
38
|
export type ConsoleBuffer = {
|
|
32
39
|
ingestConsoleApi(p: unknown): void;
|
|
@@ -43,7 +50,9 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
|
|
|
43
50
|
try {
|
|
44
51
|
const re = new RegExp(pattern.slice(1, -1));
|
|
45
52
|
return (text) => re.test(text);
|
|
46
|
-
} catch {
|
|
53
|
+
} catch {
|
|
54
|
+
// Not a valid regex: fall through and match the pattern as a literal substring.
|
|
55
|
+
}
|
|
47
56
|
}
|
|
48
57
|
return (text) => text.includes(pattern);
|
|
49
58
|
};
|
|
@@ -60,7 +69,8 @@ const stringifyRemoteObject = (
|
|
|
60
69
|
try {
|
|
61
70
|
return JSON.stringify(arg.value);
|
|
62
71
|
} catch {
|
|
63
|
-
|
|
72
|
+
// Circular or BigInt payloads cannot be JSON-encoded; fall back to the CDP description.
|
|
73
|
+
return arg.description ?? arg.type ?? "";
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
if (arg.description !== undefined) return arg.description;
|
|
@@ -69,6 +79,8 @@ const stringifyRemoteObject = (
|
|
|
69
79
|
|
|
70
80
|
const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
71
81
|
switch (type) {
|
|
82
|
+
case undefined:
|
|
83
|
+
return "log";
|
|
72
84
|
case "error":
|
|
73
85
|
case "assert":
|
|
74
86
|
return "error";
|
|
@@ -86,6 +98,8 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
|
|
|
86
98
|
|
|
87
99
|
const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
|
|
88
100
|
switch (level) {
|
|
101
|
+
case undefined:
|
|
102
|
+
return "info";
|
|
89
103
|
case "error":
|
|
90
104
|
return "error";
|
|
91
105
|
case "warning":
|
package/src/cdp/discovery.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type Result, err, ok } from "../util/result";
|
|
|
6
6
|
import { userDataDirCandidates } from "../profile/paths";
|
|
7
7
|
import { type CdpError, cdpError } from "./errors";
|
|
8
8
|
import { asString, errnoCode, isRecord } from "../util/guards";
|
|
9
|
+
import { sleep } from "../util/sleep";
|
|
9
10
|
|
|
10
11
|
const PORT_PROBE_DEADLINE_MS = 30_000;
|
|
11
12
|
const PORT_PROBE_INTERVAL_MS = 1_000;
|
|
@@ -40,24 +41,57 @@ const probePort = (port: number): Promise<Result<void, CdpError>> =>
|
|
|
40
41
|
sock.destroy();
|
|
41
42
|
resolve(r);
|
|
42
43
|
};
|
|
43
|
-
sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
|
|
44
|
-
sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
|
|
45
|
-
sock.once("connect", () => finish(ok(undefined)));
|
|
44
|
+
sock.setTimeout(1000, () => { finish(err(cdpError("discovery_failed", "probe timeout"))); });
|
|
45
|
+
sock.once("error", (e) => { finish(err(cdpError("discovery_failed", e.message))); });
|
|
46
|
+
sock.once("connect", () => { finish(ok(undefined)); });
|
|
46
47
|
});
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
export type PortProbe = (port: number) => Promise<Result<void, CdpError>>;
|
|
50
|
+
|
|
51
|
+
export type PortRaceOptions = {
|
|
52
|
+
readonly probe?: PortProbe;
|
|
53
|
+
readonly deadlineMs?: number;
|
|
54
|
+
readonly intervalMs?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Polls every candidate port at once under ONE deadline and settles on the first that answers; a stale DevToolsActivePort can no longer hold discovery for its own full deadline before the next candidate is tried.
|
|
58
|
+
export const firstReadyPort = async <C extends { readonly port: number }>(
|
|
59
|
+
candidates: ReadonlyArray<C>,
|
|
60
|
+
opts: PortRaceOptions = {},
|
|
61
|
+
): Promise<Result<C, CdpError>> => {
|
|
62
|
+
const probe = opts.probe ?? probePort;
|
|
63
|
+
const deadlineMs = opts.deadlineMs ?? PORT_PROBE_DEADLINE_MS;
|
|
64
|
+
const intervalMs = opts.intervalMs ?? PORT_PROBE_INTERVAL_MS;
|
|
65
|
+
if (candidates.length === 0) return err(cdpError("discovery_failed", "no DevTools port candidates to probe"));
|
|
66
|
+
|
|
67
|
+
// One controller ends every loser the moment a winner appears (or the deadline passes).
|
|
68
|
+
const stop = new AbortController();
|
|
69
|
+
const deadline = setTimeout(() => { stop.abort(); }, deadlineMs);
|
|
70
|
+
const lastMessages = new Map<number, string>();
|
|
71
|
+
|
|
72
|
+
const poll = async (c: C): Promise<C> => {
|
|
73
|
+
while (!stop.signal.aborted) {
|
|
74
|
+
const r = await probe(c.port);
|
|
75
|
+
if (r.success) return c;
|
|
76
|
+
lastMessages.set(c.port, r.error.message);
|
|
77
|
+
await sleep(intervalMs, stop.signal);
|
|
78
|
+
}
|
|
79
|
+
throw new Error(lastMessages.get(c.port) ?? "aborted");
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return ok(await Promise.any(candidates.map(poll)));
|
|
84
|
+
} catch {
|
|
85
|
+
const ports = candidates.map((k) => `127.0.0.1:${k.port}`).join(", ");
|
|
86
|
+
const detail = [...lastMessages.entries()].map(([port, m]) => `${port}: ${m}`).join("; ") || "unknown";
|
|
87
|
+
return err(cdpError(
|
|
88
|
+
"discovery_failed",
|
|
89
|
+
`Chrome's remote-debugging page is open, but DevTools is not live yet on ${ports} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${detail})`,
|
|
90
|
+
));
|
|
91
|
+
} finally {
|
|
92
|
+
clearTimeout(deadline);
|
|
93
|
+
stop.abort();
|
|
56
94
|
}
|
|
57
|
-
return err(cdpError(
|
|
58
|
-
"discovery_failed",
|
|
59
|
-
`Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
|
|
60
|
-
));
|
|
61
95
|
};
|
|
62
96
|
|
|
63
97
|
const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
|
|
@@ -81,7 +115,7 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
|
|
|
81
115
|
},
|
|
82
116
|
);
|
|
83
117
|
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
84
|
-
req.on("error", () => resolve(null));
|
|
118
|
+
req.on("error", () => { resolve(null); });
|
|
85
119
|
req.end();
|
|
86
120
|
});
|
|
87
121
|
|
|
@@ -126,7 +160,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
126
160
|
raw = await readFile(portFile, "utf8");
|
|
127
161
|
try {
|
|
128
162
|
mtimeMs = (await stat(portFile)).mtimeMs;
|
|
129
|
-
} catch {
|
|
163
|
+
} catch {
|
|
164
|
+
// The file was readable a moment ago; without an mtime it is simply treated as fresh.
|
|
165
|
+
}
|
|
130
166
|
} catch (e) {
|
|
131
167
|
// EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
|
|
132
168
|
const code = errnoCode(e);
|
|
@@ -175,13 +211,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
175
211
|
const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
|
|
176
212
|
|
|
177
213
|
// Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
if (!ready.success) {
|
|
182
|
-
lastErr = err(ready.error);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
214
|
+
const ready = await firstReadyPort(ordered);
|
|
215
|
+
if (ready.success) {
|
|
216
|
+
const c = ready.data;
|
|
185
217
|
const liveUrl = await queryLiveWsUrl(c.port);
|
|
186
218
|
return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
|
|
187
219
|
}
|
|
@@ -192,7 +224,7 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
|
|
|
192
224
|
if (liveUrl) return ok({ wsUrl: liveUrl });
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
return
|
|
227
|
+
return err(ready.error);
|
|
196
228
|
};
|
|
197
229
|
|
|
198
230
|
export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
|
package/src/cdp/events.ts
CHANGED
|
@@ -39,6 +39,11 @@ const ConsoleArg = Type.Object(
|
|
|
39
39
|
|
|
40
40
|
export const EVENTS = {
|
|
41
41
|
"Inspector.detached": ev(Anything),
|
|
42
|
+
// Browser-level notice that a session ended; carries the session id in params rather than on the envelope.
|
|
43
|
+
"Target.detachedFromTarget": ev(
|
|
44
|
+
Type.Object({ sessionId: Type.String(), targetId: Type.Optional(Type.String()) }, open),
|
|
45
|
+
),
|
|
46
|
+
"Page.javascriptDialogClosed": ev(Anything),
|
|
42
47
|
|
|
43
48
|
"Page.javascriptDialogOpening": ev(
|
|
44
49
|
Type.Object(
|
|
@@ -52,6 +57,7 @@ export const EVENTS = {
|
|
|
52
57
|
),
|
|
53
58
|
"Page.loadEventFired": ev(Anything),
|
|
54
59
|
"Page.frameNavigated": ev(Anything),
|
|
60
|
+
"Page.downloadWillBegin": ev(Anything),
|
|
55
61
|
|
|
56
62
|
"Runtime.consoleAPICalled": ev(
|
|
57
63
|
Type.Object(
|
|
@@ -158,13 +164,13 @@ export const decodeEvent = <E extends CdpEventName>(
|
|
|
158
164
|
name: E,
|
|
159
165
|
raw: unknown,
|
|
160
166
|
): Result<EventParamsOf<E>, CdpError> => {
|
|
167
|
+
// `name` is a key of EVENTS, so the lookup cannot miss.
|
|
161
168
|
const spec = EVENTS[name];
|
|
162
|
-
if (spec === undefined) {
|
|
163
|
-
return err(cdpError("invalid_response", "unknown CDP event", name));
|
|
164
|
-
}
|
|
165
169
|
if (!spec.validate.Check(raw)) {
|
|
166
170
|
const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
|
|
167
171
|
return err(cdpError("invalid_response", why, name));
|
|
168
172
|
}
|
|
173
|
+
// Guarded assertion: same generic-key correlation limit as decodeResult in cdp/commands.ts.
|
|
174
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
|
|
169
175
|
return ok(raw as EventParamsOf<E>);
|
|
170
176
|
};
|
|
@@ -1,37 +1,40 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
1
2
|
import { decodeEvent, type EventParamsOf } from "./events";
|
|
2
|
-
import { createRecordStore } from "./record-store";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
3
|
+
import { createRecordStore, type DrainPage } from "./record-store";
|
|
4
|
+
|
|
5
|
+
// Only the buffer itself may update a record in place as follow-up events arrive; everything it hands out is readonly.
|
|
6
|
+
// The one declaration of a network record: the buffer produces it and browser_network_requests validates
|
|
7
|
+
// replayed details against it, so the two cannot drift.
|
|
8
|
+
export const NetworkRecordSchema = Type.Object({
|
|
9
|
+
requestId: Type.String(),
|
|
10
|
+
method: Type.String(),
|
|
11
|
+
url: Type.String(),
|
|
12
|
+
status: Type.Optional(Type.Number()),
|
|
13
|
+
statusText: Type.Optional(Type.String()),
|
|
14
|
+
type: Type.String(),
|
|
15
|
+
mimeType: Type.Optional(Type.String()),
|
|
16
|
+
requestStartedMs: Type.Number(),
|
|
17
|
+
durationMs: Type.Optional(Type.Number()),
|
|
18
|
+
requestBodySize: Type.Number(),
|
|
19
|
+
responseBodySize: Type.Optional(Type.Number()),
|
|
20
|
+
failed: Type.Optional(Type.Boolean()),
|
|
21
|
+
errorText: Type.Optional(Type.String()),
|
|
22
|
+
body: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
type MutableNetworkRecord = Static<typeof NetworkRecordSchema>;
|
|
26
|
+
export type NetworkRecord = Readonly<MutableNetworkRecord>;
|
|
20
27
|
|
|
21
28
|
export type NetworkFilter = {
|
|
22
|
-
urlPattern?: string;
|
|
23
|
-
methodFilter?: string
|
|
24
|
-
statusFilter?: { min?: number; max?: number };
|
|
25
|
-
resourceTypes?: string
|
|
26
|
-
sinceMs?: number;
|
|
27
|
-
limit?: number;
|
|
29
|
+
readonly urlPattern?: string;
|
|
30
|
+
readonly methodFilter?: ReadonlyArray<string>;
|
|
31
|
+
readonly statusFilter?: { readonly min?: number; readonly max?: number };
|
|
32
|
+
readonly resourceTypes?: ReadonlyArray<string>;
|
|
33
|
+
readonly sinceMs?: number;
|
|
34
|
+
readonly limit?: number;
|
|
28
35
|
};
|
|
29
36
|
|
|
30
|
-
export type DrainResult =
|
|
31
|
-
readonly records: NetworkRecord[];
|
|
32
|
-
readonly total: number;
|
|
33
|
-
readonly bufferOverflowed: boolean;
|
|
34
|
-
};
|
|
37
|
+
export type DrainResult = DrainPage<NetworkRecord>;
|
|
35
38
|
|
|
36
39
|
export type NetworkBuffer = {
|
|
37
40
|
ingestRequestWillBeSent(p: unknown): void;
|
|
@@ -47,7 +50,9 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
|
|
|
47
50
|
try {
|
|
48
51
|
const re = new RegExp(pattern.slice(1, -1));
|
|
49
52
|
return (url) => re.test(url);
|
|
50
|
-
} catch {
|
|
53
|
+
} catch {
|
|
54
|
+
// Not a valid regex: fall through and match the pattern as a literal substring.
|
|
55
|
+
}
|
|
51
56
|
}
|
|
52
57
|
return (url) => url.includes(pattern);
|
|
53
58
|
};
|
|
@@ -55,13 +60,13 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
|
|
|
55
60
|
type UpdateEvent = "Network.responseReceived" | "Network.loadingFinished" | "Network.loadingFailed";
|
|
56
61
|
|
|
57
62
|
export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
58
|
-
const records = createRecordStore<string,
|
|
63
|
+
const records = createRecordStore<string, MutableNetworkRecord>(capacity);
|
|
59
64
|
|
|
60
65
|
// All three follow-up events say the same thing first: decode, find the request they belong to, give up quietly if it is gone.
|
|
61
66
|
const update = <E extends UpdateEvent>(
|
|
62
67
|
method: E,
|
|
63
68
|
p: unknown,
|
|
64
|
-
apply: (r:
|
|
69
|
+
apply: (r: MutableNetworkRecord, params: EventParamsOf<E>) => void,
|
|
65
70
|
): void => {
|
|
66
71
|
const decoded = decodeEvent(method, p);
|
|
67
72
|
if (!decoded.success) return;
|
|
@@ -80,6 +85,7 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
|
|
|
80
85
|
const url = params?.request.url;
|
|
81
86
|
const method = params?.request.method;
|
|
82
87
|
if (!id || !url || !method) return;
|
|
88
|
+
// Delete before set: the store evicts by insertion order, so a request id that is re-sent (redirects reuse it) must move to the newest slot rather than keep its old age.
|
|
83
89
|
records.delete(id);
|
|
84
90
|
const postData = params.request.postData;
|
|
85
91
|
records.set(id, {
|