cdp-toolkit 1.9.0 → 1.9.4
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 +507 -0
- package/CONTRACT.md +62 -5
- package/README.md +74 -10
- package/dist/cli.js +2940 -138
- package/dist/index.js +994 -90
- package/dist/mcp.js +1221 -137
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +111 -12
- package/src/driver.ts +254 -3
- package/src/manifest.ts +42 -14
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +8 -6
- package/src/workers.ts +69 -7
package/src/bidi/driver.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* BiDi implementation of the browser-neutral Driver contract in ../driver.ts (ADR-001), for
|
|
3
|
-
* Firefox.
|
|
4
|
-
*
|
|
3
|
+
* Firefox. THE CONNECTION IDENTITY IS A BiDi ws ENDPOINT, not a port: createFirefoxDriver(port)
|
|
4
|
+
* derives the loopback endpoint of a Firefox this toolkit launched, while
|
|
5
|
+
* createFirefoxDriverForEndpoint(wsUrl) takes a user-supplied one for ATTACH mode (a Firefox the
|
|
6
|
+
* user started with --remote-debugging-port; see ../backend.ts). Attach is a first-class path,
|
|
7
|
+
* not a workaround — what is impossible is RELAUNCHING the binary to open a port on a running
|
|
8
|
+
* instance (bidi/launch.ts's header), not connecting to a port that is already open.
|
|
9
|
+
* lifetime is "session": a BiDi socket cannot be resumed, so this file memoizes ONE
|
|
10
|
+
* BidiConnection per endpoint at module scope (the connections map below), page()
|
|
5
11
|
* retains a holder, release() decrements it, and only BrowserDriver.dispose() ever closes the
|
|
6
|
-
* socket
|
|
12
|
+
* socket — after sending session.end, so an attached Firefox (which outlives us) gets its single
|
|
13
|
+
* session slot back. There is deliberately no reconnect-and-resume path: if the socket dies, every pending
|
|
7
14
|
* and future call on it rejects with "disconnected" rather than silently starting a fresh
|
|
8
15
|
* session underneath a caller who still thinks they hold the old one. (driver.ts's LifetimeModel
|
|
9
16
|
* doc says a "session" driver "MUST re-establish itself transparently if the transport dies";
|
|
@@ -24,19 +31,20 @@
|
|
|
24
31
|
*/
|
|
25
32
|
import type { TargetSelector } from "../types.ts";
|
|
26
33
|
import {
|
|
27
|
-
UID_STAMP_ATTR, isDriverError, interpolatePoints,
|
|
34
|
+
UID_STAMP_ATTR, isDriverError, interpolatePoints, clearViewportRecord, readViewportRecord, renderRestoreFailureNote, writeViewportRecord,
|
|
28
35
|
type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
29
36
|
type DragDestination, type DragOptions,
|
|
30
37
|
type SetCookieParams,
|
|
31
38
|
type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
|
|
32
39
|
type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
|
|
33
|
-
type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
|
|
40
|
+
type ScreenshotOptions, type ScreenshotResult, type ScrollOptions, type SnapshotNode, type UidStability,
|
|
41
|
+
type BandConsumer, type CaptureDelivery,
|
|
34
42
|
} from "../driver.ts";
|
|
35
43
|
import { assertLeaseOk } from "../leases.ts";
|
|
36
44
|
import { resolveLiveLabel } from "../origins.ts";
|
|
37
45
|
import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
|
|
38
46
|
import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
|
|
39
|
-
import { BidiConnection, BidiError,
|
|
47
|
+
import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
|
|
40
48
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
41
49
|
import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
|
|
42
50
|
import type {
|
|
@@ -288,37 +296,63 @@ const AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}
|
|
|
288
296
|
* network.intercept rides network.addIntercept + network.beforeRequestSent, verified working
|
|
289
297
|
* (see intercept()'s comment on why urlPatterns is never populated). screenshot.fullPage uses
|
|
290
298
|
* captureScreenshot's origin:"document", screenshot.element uses its clip:{type:"element"}, both verified.
|
|
299
|
+
* screenshot.renderSize IS declared, and it is the one screenshot capability here that is not a
|
|
300
|
+
* Chrome-only gap: it rides browsingContext.setViewport, the very command emulate.deviceMetrics
|
|
301
|
+
* above already rides, applied for one capture and undone afterwards. Refusing it would be dishonest
|
|
302
|
+
* in the opposite direction — claiming a missing primitive this driver demonstrably has. Contrast
|
|
303
|
+
* screenshot.scale, absent because captureScreenshot genuinely has no scale parameter.
|
|
304
|
+
* screenshot.tile is absent for a THIRD reason, neither "has it" nor "no such parameter": the
|
|
305
|
+
* primitive plausibly exists (clip:{type:"box"}) but nothing about band tiling has been measured on
|
|
306
|
+
* this backend, and the Chrome declaration rests entirely on measurement. An undeclared capability
|
|
307
|
+
* that turns out to work is a follow-up; a declared one that silently mis-stitches is a wrong image
|
|
308
|
+
* the caller cannot tell from a right one.
|
|
291
309
|
*/
|
|
292
310
|
const BIDI_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
293
|
-
"emulate.deviceMetrics", "screenshot.fullPage", "screenshot.element", "network.intercept", "locate.xpath",
|
|
311
|
+
"emulate.deviceMetrics", "screenshot.fullPage", "screenshot.element", "screenshot.renderSize", "network.intercept", "locate.xpath",
|
|
294
312
|
]);
|
|
295
313
|
|
|
296
314
|
/* --------------------------------- module-scope session memoization --------------------------------- */
|
|
297
|
-
// "session" LifetimeModel: ONE BidiConnection per
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
315
|
+
// "session" LifetimeModel: ONE BidiConnection per BiDi ws endpoint, shared by every page()
|
|
316
|
+
// acquisition on that endpoint. Keyed by the endpoint URL string rather than a port because attach
|
|
317
|
+
// mode's endpoint may be any host/port/path (../backend.ts's normalizeBidiEndpoint), and the URL is
|
|
318
|
+
// the only identity both modes share. getConnection retains a holder on every call (fresh or
|
|
319
|
+
// reused); callers release() it.
|
|
320
|
+
const connections = new Map<string, BidiConnection>();
|
|
321
|
+
async function getConnection(endpoint: string, timeoutMs?: number): Promise<BidiConnection> {
|
|
322
|
+
const existing = connections.get(endpoint);
|
|
302
323
|
if (existing) { existing.retain(); return existing; }
|
|
303
324
|
// Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
|
|
304
325
|
// gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
|
|
305
326
|
// reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
|
|
306
327
|
// next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
|
|
307
328
|
// and answer a live prompt at all.
|
|
308
|
-
const conn = await
|
|
329
|
+
const conn = await connectBidiSessionUrl(endpoint, {
|
|
309
330
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
310
331
|
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } },
|
|
332
|
+
}).catch((e: unknown) => {
|
|
333
|
+
// Firefox allows exactly ONE active BiDi session and never reaps an abandoned one (verified:
|
|
334
|
+
// still held 10s after an abrupt socket death). A client killed without session.end therefore
|
|
335
|
+
// leaves the slot occupied until Firefox restarts, and the raw wire message ("Maximum number of
|
|
336
|
+
// active sessions") tells the user nothing about how to get unstuck. Force-ending a session we
|
|
337
|
+
// do not own is not an option (there is no id to end), so this maps the error to instructions.
|
|
338
|
+
if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
|
|
339
|
+
throw driverError("disconnected",
|
|
340
|
+
`Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` +
|
|
341
|
+
`Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` +
|
|
342
|
+
`session that Firefox does not reap.)`);
|
|
343
|
+
}
|
|
344
|
+
throw e;
|
|
311
345
|
}); // already retained once
|
|
312
|
-
connections.set(
|
|
346
|
+
connections.set(endpoint, conn);
|
|
313
347
|
return conn;
|
|
314
348
|
}
|
|
315
349
|
|
|
316
350
|
/**
|
|
317
|
-
* Contexts that already carry a beacon preload script, keyed
|
|
351
|
+
* Contexts that already carry a beacon preload script, keyed endpoint:context ->
|
|
318
352
|
* the script id BiDi handed back.
|
|
319
353
|
*
|
|
320
354
|
* WHY FIREFOX NEEDS NO HELD CONNECTION where Chrome does. This driver's
|
|
321
|
-
* LifetimeModel is "session": ONE BidiConnection is memoized per
|
|
355
|
+
* LifetimeModel is "session": ONE BidiConnection is memoized per endpoint above and
|
|
322
356
|
* lives until dispose(), so a preload script registered on it stays registered
|
|
323
357
|
* across navigations for free. Chrome's equivalent registration is cleared when
|
|
324
358
|
* the registering client disconnects, and this toolkit's CDP lifetime is
|
|
@@ -771,7 +805,101 @@ class BidiPageDriver implements PageDriver {
|
|
|
771
805
|
}
|
|
772
806
|
}
|
|
773
807
|
|
|
774
|
-
|
|
808
|
+
/**
|
|
809
|
+
* A capture, optionally rendered at an emulated viewport that is put back afterwards. Same
|
|
810
|
+
* contract and the same restore-on-every-exit discipline as CdpPageDriver.screenshot — read that
|
|
811
|
+
* method's comment for the reasoning; only the commands differ (browsingContext.setViewport here,
|
|
812
|
+
* Emulation.setDeviceMetricsOverride there).
|
|
813
|
+
*
|
|
814
|
+
* NOT LIVE-FIRED. Firefox was not launched for this change, so unlike the Chrome path every claim
|
|
815
|
+
* here rests on the shape of browsingContext.setViewport plus the unit tests, not on a measured
|
|
816
|
+
* page. What IS verified is that the restore cannot be skipped: the ordering and the finally-path
|
|
817
|
+
* are shared logic exercised by test/screenshot-scale.test.ts against a stub. Two specifics to
|
|
818
|
+
* confirm when Firefox is next driven: whether setViewport's null viewport reverts to the real
|
|
819
|
+
* window (CDP's clear does not, for an override another session set), and whether it reflows
|
|
820
|
+
* synchronously the way setDeviceMetricsOverride was measured to.
|
|
821
|
+
*/
|
|
822
|
+
async screenshot<T>(opts?: ScreenshotOptions, _consumeBands?: BandConsumer<T>): Promise<CaptureDelivery<T>> {
|
|
823
|
+
const renderSize =
|
|
824
|
+
opts?.renderWidth !== undefined && opts?.renderHeight !== undefined
|
|
825
|
+
? { width: opts.renderWidth, height: opts.renderHeight }
|
|
826
|
+
: undefined;
|
|
827
|
+
if (!renderSize) return this.capture(opts);
|
|
828
|
+
const previous = await readViewportRecord(this.browser.scheme, this.contextId);
|
|
829
|
+
try {
|
|
830
|
+
// devicePixelRatio is deliberately not sent: this changes the render SIZE, and omitting the
|
|
831
|
+
// field leaves whatever ratio an earlier emulate set in place (the CDP path inherits it the
|
|
832
|
+
// same way).
|
|
833
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: renderSize.width, height: renderSize.height } });
|
|
834
|
+
} catch (e) {
|
|
835
|
+
throw mapBidiError(e);
|
|
836
|
+
}
|
|
837
|
+
let shot: ScreenshotResult | undefined;
|
|
838
|
+
let captureError: unknown;
|
|
839
|
+
try {
|
|
840
|
+
shot = await this.capture(opts);
|
|
841
|
+
} catch (e) {
|
|
842
|
+
captureError = e;
|
|
843
|
+
}
|
|
844
|
+
let restoreError: string | undefined;
|
|
845
|
+
try {
|
|
846
|
+
// Re-apply the tracked viewport, never a bare null: a null viewport reverts to the real
|
|
847
|
+
// window and would destroy an override the caller set with emulate earlier.
|
|
848
|
+
if (previous) {
|
|
849
|
+
await this.conn.send("browsingContext.setViewport", {
|
|
850
|
+
context: this.contextId,
|
|
851
|
+
viewport: { width: previous.width, height: previous.height },
|
|
852
|
+
...(previous.deviceScaleFactor != null ? { devicePixelRatio: previous.deviceScaleFactor } : {}),
|
|
853
|
+
});
|
|
854
|
+
} else {
|
|
855
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null });
|
|
856
|
+
}
|
|
857
|
+
} catch (e) {
|
|
858
|
+
restoreError = e instanceof Error ? e.message : String(e);
|
|
859
|
+
}
|
|
860
|
+
if (!shot) {
|
|
861
|
+
if (restoreError !== undefined && captureError instanceof Error) {
|
|
862
|
+
captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
|
|
863
|
+
}
|
|
864
|
+
throw captureError;
|
|
865
|
+
}
|
|
866
|
+
const result: ScreenshotResult = { ...shot, renderSize, renderRestored: restoreError === undefined };
|
|
867
|
+
if (restoreError !== undefined) result.renderRestoreError = restoreError;
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
/** The capture itself, with no viewport emulation of its own. Split out so screenshot()'s restore
|
|
871
|
+
* wraps EVERY way this can fail, the throwing paths included. */
|
|
872
|
+
private async capture(opts?: ScreenshotOptions): Promise<ScreenshotResult> {
|
|
873
|
+
// ADR-001 param-level gap, same shape as drag's mode:"html5" (see resolveDragMode in
|
|
874
|
+
// ../shared-tools.ts): browsingContext.captureScreenshot has NO scale parameter — its only
|
|
875
|
+
// knobs are origin, format and clip — so a scale asked for here is refused outright rather
|
|
876
|
+
// than silently captured at 1x, which would hand back an image the caller never asked for.
|
|
877
|
+
if (opts?.scale !== undefined && opts.scale !== 1) {
|
|
878
|
+
throw driverError(
|
|
879
|
+
"unsupported",
|
|
880
|
+
`screenshot scale ${opts.scale} is not supported by this backend (WebDriver BiDi's ` +
|
|
881
|
+
"browsingContext.captureScreenshot has no scale parameter). Call emulate with " +
|
|
882
|
+
`deviceScaleFactor:${opts.scale} first and capture at scale 1, or use --browser chrome.`,
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
// Same ADR-001 param-level shape, and the reason is HONESTY rather than a missing primitive.
|
|
886
|
+
// browsingContext.captureScreenshot does take clip:{type:"box",x,y,width,height}, so a banded
|
|
887
|
+
// capture is probably implementable here — but Firefox was not launched for this change, so
|
|
888
|
+
// every property band tiling depends on is unmeasured on this backend: whether a box clip
|
|
889
|
+
// renders content outside the viewport at all, whether clip.y is CSS px, whether fixed elements
|
|
890
|
+
// repeat per band, whether seams are pixel-exact. Each of those was measured on Chrome before
|
|
891
|
+
// the Chrome path was declared. Shipping an unverified twin would put a silently-wrong stitch in
|
|
892
|
+
// front of a caller, so this refuses and says why. `tile:false` and the default (auto, which
|
|
893
|
+
// never bands here) ask this backend for nothing and are untouched.
|
|
894
|
+
if (opts?.tile === true) {
|
|
895
|
+
throw driverError(
|
|
896
|
+
"unsupported",
|
|
897
|
+
"screenshot tile:true is not supported by this backend: band tiling is declared only where the band " +
|
|
898
|
+
"arithmetic has been measured against a real browser, and Firefox was not driven for it " +
|
|
899
|
+
'(browsingContext.captureScreenshot\'s clip:{type:"box"} is the primitive it would ride, untested). ' +
|
|
900
|
+
"Capture a smaller region or a single element, or use --browser chrome for a tiled capture.",
|
|
901
|
+
);
|
|
902
|
+
}
|
|
775
903
|
const format = opts?.format ?? "png";
|
|
776
904
|
const params: BrowsingContextCaptureScreenshotParameters = {
|
|
777
905
|
context: this.contextId,
|
|
@@ -792,14 +920,37 @@ class BidiPageDriver implements PageDriver {
|
|
|
792
920
|
// a real parity gap noted in the footer rather than smoothed over.
|
|
793
921
|
async emulate(opts: EmulationOptions): Promise<{ applied: string[] }> {
|
|
794
922
|
if (opts.clearOverrides) {
|
|
923
|
+
// CHECKED FOR THE SAME CROSS-PROCESS NO-OP THE CDP DRIVER HAD (see that method's comment) —
|
|
924
|
+
// by protocol semantics, since Firefox was not launched to measure this directly. The W3C
|
|
925
|
+
// WebDriver BiDi spec's "remote end steps for browsingContext.setViewport" (and the "set
|
|
926
|
+
// viewport" / "set device pixel ratio override" algorithms it calls) key EVERY step off
|
|
927
|
+
// |navigable| — the browsing context — never off which session or connection issued an
|
|
928
|
+
// earlier override: "If |viewport| is not null, set the width/height of |navigable|'s layout
|
|
929
|
+
// viewport... Otherwise, set the |navigable|'s layout viewport to the implementation-defined
|
|
930
|
+
// default", and the device-pixel-ratio twin literally does `Remove |navigable| from [device
|
|
931
|
+
// pixel ratio overrides]`. There is no per-session ownership check anywhere in that algorithm,
|
|
932
|
+
// unlike CDP's clearDeviceMetricsOverride (measured to be a no-op unless the calling
|
|
933
|
+
// connection is also the one that set the override). So a `viewport: null` from ANY BiDi
|
|
934
|
+
// connection reverts THIS navigable to its real default, regardless of which connection —
|
|
935
|
+
// this toolkit's own per-call ones included — set the override being cleared. No defect,
|
|
936
|
+
// no "set-then-clear" workaround needed; left as it was.
|
|
795
937
|
await this.conn.send("emulation.setUserAgentOverride", { userAgent: null, contexts: [this.contextId] }).catch(() => undefined);
|
|
796
938
|
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null, devicePixelRatio: null }).catch(() => undefined);
|
|
939
|
+
// No override of ours left to put back: a render-size capture should restore to null.
|
|
940
|
+
await clearViewportRecord(this.browser.scheme, this.contextId);
|
|
797
941
|
return { applied: [] };
|
|
798
942
|
}
|
|
799
943
|
const applied: string[] = [];
|
|
800
944
|
try {
|
|
801
945
|
if (opts.width != null && opts.height != null) {
|
|
802
946
|
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: opts.width, height: opts.height }, ...(opts.deviceScaleFactor != null ? { devicePixelRatio: opts.deviceScaleFactor } : {}) });
|
|
947
|
+
// Recorded so a later render-size capture puts THIS back rather than reverting to the real
|
|
948
|
+
// window. See ViewportRecord in ../driver.ts.
|
|
949
|
+
await writeViewportRecord(this.browser.scheme, this.contextId, {
|
|
950
|
+
width: opts.width,
|
|
951
|
+
height: opts.height,
|
|
952
|
+
...(opts.deviceScaleFactor != null ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
|
|
953
|
+
});
|
|
803
954
|
applied.push("deviceMetrics");
|
|
804
955
|
}
|
|
805
956
|
if (opts.userAgent != null) {
|
|
@@ -1053,10 +1204,12 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1053
1204
|
readonly capabilities = BIDI_CAPABILITIES;
|
|
1054
1205
|
readonly uidStability: UidStability = "document-stamp";
|
|
1055
1206
|
readonly snapshotFidelity = "dom-heuristic" as const;
|
|
1056
|
-
|
|
1207
|
+
/** `endpoint` is a BiDi ws URL (see getConnection): derived from a port for a launched
|
|
1208
|
+
* Firefox, user-supplied for an attached one. It is this driver's whole identity. */
|
|
1209
|
+
constructor(private readonly endpoint: string, private readonly timeoutMs?: number) {}
|
|
1057
1210
|
|
|
1058
1211
|
async listPages(): Promise<PageInfo[]> {
|
|
1059
|
-
const conn = await getConnection(this.
|
|
1212
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1060
1213
|
try {
|
|
1061
1214
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
|
|
1062
1215
|
return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
|
|
@@ -1065,7 +1218,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1065
1218
|
}
|
|
1066
1219
|
}
|
|
1067
1220
|
async newPage(url?: string): Promise<PageInfo> {
|
|
1068
|
-
const conn = await getConnection(this.
|
|
1221
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1069
1222
|
try {
|
|
1070
1223
|
const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => { throw mapBidiError(e); });
|
|
1071
1224
|
// Established fact: navigating the default about:home context to a data: URL is rejected,
|
|
@@ -1077,7 +1230,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1077
1230
|
}
|
|
1078
1231
|
}
|
|
1079
1232
|
async closePage(id: string): Promise<{ success: boolean }> {
|
|
1080
|
-
const conn = await getConnection(this.
|
|
1233
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1081
1234
|
try {
|
|
1082
1235
|
await conn.send("browsingContext.close", { context: id }).catch((e) => { throw mapBidiError(e); });
|
|
1083
1236
|
return { success: true };
|
|
@@ -1086,7 +1239,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1086
1239
|
}
|
|
1087
1240
|
}
|
|
1088
1241
|
async activatePage(id: string): Promise<PageInfo> {
|
|
1089
|
-
const conn = await getConnection(this.
|
|
1242
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1090
1243
|
try {
|
|
1091
1244
|
await conn.send("browsingContext.activate", { context: id }).catch((e) => { throw mapBidiError(e); });
|
|
1092
1245
|
const ctx = await resolveContext(conn, id);
|
|
@@ -1096,7 +1249,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1096
1249
|
}
|
|
1097
1250
|
}
|
|
1098
1251
|
async page(selector: TargetSelector): Promise<PageDriver> {
|
|
1099
|
-
const conn = await getConnection(this.
|
|
1252
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1100
1253
|
let ctx: BrowsingContextInfo;
|
|
1101
1254
|
try {
|
|
1102
1255
|
ctx = await resolveContext(conn, selector);
|
|
@@ -1124,10 +1277,10 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1124
1277
|
* an annotation that must never fail real work.
|
|
1125
1278
|
*/
|
|
1126
1279
|
async installActivityBeacon(contextId: string): Promise<boolean> {
|
|
1127
|
-
const conn = await getConnection(this.
|
|
1280
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
|
|
1128
1281
|
if (!conn) return false;
|
|
1129
1282
|
try {
|
|
1130
|
-
const key = `${this.
|
|
1283
|
+
const key = `${this.endpoint}:${contextId}`;
|
|
1131
1284
|
if (!bidiBeaconScripts.has(key)) {
|
|
1132
1285
|
const registered = await conn.send("script.addPreloadScript", {
|
|
1133
1286
|
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
@@ -1151,7 +1304,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1151
1304
|
/** Read the beacon timestamp for a context, or null when it has none. Gate-free;
|
|
1152
1305
|
* see the declaration in ../driver.ts for why that is required here. */
|
|
1153
1306
|
async readActivityBeacon(contextId: string): Promise<number | null> {
|
|
1154
|
-
const conn = await getConnection(this.
|
|
1307
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
|
|
1155
1308
|
if (!conn) return null;
|
|
1156
1309
|
try {
|
|
1157
1310
|
const res = await conn.send("script.evaluate", {
|
|
@@ -1171,19 +1324,35 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1171
1324
|
|
|
1172
1325
|
async dispose(): Promise<void> {
|
|
1173
1326
|
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
1174
|
-
if (key.startsWith(`${this.
|
|
1327
|
+
if (key.startsWith(`${this.endpoint}:`)) bidiBeaconScripts.delete(key);
|
|
1175
1328
|
}
|
|
1176
|
-
const conn = connections.get(this.
|
|
1329
|
+
const conn = connections.get(this.endpoint);
|
|
1177
1330
|
if (!conn) return;
|
|
1178
|
-
connections.delete(this.
|
|
1331
|
+
connections.delete(this.endpoint);
|
|
1332
|
+
// Cleanly END the BiDi session so Firefox frees its single session slot for the next
|
|
1333
|
+
// attach (a launch-mode Firefox is killed anyway; this matters for attach mode where the
|
|
1334
|
+
// user's browser lives on). Verified: closing the socket alone does NOT free the slot, and
|
|
1335
|
+
// Firefox never reaps the orphan, so skipping this would make the SECOND attach fail.
|
|
1336
|
+
// Best-effort: harmless if the socket is already gone.
|
|
1337
|
+
await conn.send("session.end", {}).catch(() => undefined);
|
|
1179
1338
|
conn.dispose();
|
|
1180
1339
|
}
|
|
1181
1340
|
}
|
|
1182
1341
|
|
|
1183
|
-
/** Construct the BiDi implementation of the frozen Driver contract
|
|
1184
|
-
* port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's
|
|
1342
|
+
/** Construct the BiDi implementation of the frozen Driver contract for a LAUNCHED Firefox. `port`
|
|
1343
|
+
* is a Firefox debug port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's
|
|
1344
|
+
* convention; it is only ever a shorthand for the loopback endpoint below. Note port 0 is a legal
|
|
1345
|
+
* argument for capability probes (createFirefoxDriver(0)) that never open a socket. */
|
|
1185
1346
|
export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
1186
|
-
return new BidiBrowserDriver(port
|
|
1347
|
+
return new BidiBrowserDriver(`ws://127.0.0.1:${port}/session`, opts?.timeoutMs);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** Construct the same driver for ATTACH mode: `wsUrl` is the BiDi endpoint of a Firefox the user
|
|
1351
|
+
* started themselves with --remote-debugging-port, already normalized by ../backend.ts's
|
|
1352
|
+
* normalizeBidiEndpoint. This driver NEVER owns that process — dispose() ends the BiDi session and
|
|
1353
|
+
* closes the socket, and nothing anywhere in this path kills a browser it did not launch. */
|
|
1354
|
+
export function createFirefoxDriverForEndpoint(wsUrl: string, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
1355
|
+
return new BidiBrowserDriver(wsUrl, opts?.timeoutMs);
|
|
1187
1356
|
}
|
|
1188
1357
|
// resolveContext is exported for ONE reason: it is the Firefox lease choke
|
|
1189
1358
|
// point, and a choke point nothing can call directly is a choke point nothing
|
|
@@ -1192,7 +1361,8 @@ export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number })
|
|
|
1192
1361
|
export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
1193
1362
|
|
|
1194
1363
|
/* ------------------------------------------------------------------------------
|
|
1195
|
-
* BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts
|
|
1364
|
+
* BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts; end, sent by
|
|
1365
|
+
* dispose() so an attached Firefox gets its one session slot back); browsingContext
|
|
1196
1366
|
* (create, close, activate, getTree, navigate, reload, locateNodes, captureScreenshot, setViewport,
|
|
1197
1367
|
* handleUserPrompt); script (evaluate, callFunction); input (performActions, setFiles); network
|
|
1198
1368
|
* (addDataCollector, removeDataCollector, getData, addIntercept, removeIntercept, continueRequest,
|
|
@@ -1226,6 +1396,25 @@ export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
|
1226
1396
|
* this one. Neither insertText route synthesizes a <br> or a block break.
|
|
1227
1397
|
* - trace.performance / heap.snapshot / audit.lighthouse: not declared, Chrome-only domains with
|
|
1228
1398
|
* no BiDi equivalent at all.
|
|
1399
|
+
* - screenshot.scale: not declared. browsingContext.captureScreenshot's parameters are context,
|
|
1400
|
+
* origin, format and clip — no scale anywhere, and its clip types (box/element) carry no
|
|
1401
|
+
* multiplier either. A `scale` other than 1 is a refusal in screenshot() (ADR-001 param-level
|
|
1402
|
+
* gap, like drag's html5 mode), pointing at emulate's deviceScaleFactor as the real workaround.
|
|
1403
|
+
* - screenshot.renderSize: DECLARED, and the one screenshot capability here that is not a gap. It
|
|
1404
|
+
* rides browsingContext.setViewport — the same command emulate.deviceMetrics already uses —
|
|
1405
|
+
* applied for one capture and restored to the tracked viewport (or `viewport:null`) afterwards.
|
|
1406
|
+
* NOT live-fired: Firefox was not launched for this change, so the ordering and the restore rest
|
|
1407
|
+
* on the shared logic's unit tests rather than a measured page. Two things to confirm when
|
|
1408
|
+
* Firefox is next driven: whether a null viewport reverts to the real window (CDP's clear does
|
|
1409
|
+
* NOT, for an override another session set), and whether setViewport reflows synchronously the
|
|
1410
|
+
* way Emulation.setDeviceMetricsOverride was measured to on Chrome.
|
|
1411
|
+
* - screenshot.tile: not declared, and unlike screenshot.scale this is an UNMEASURED gap rather
|
|
1412
|
+
* than a missing parameter. captureScreenshot does take clip:{type:"box"}, so bands are probably
|
|
1413
|
+
* implementable — but every property band tiling rests on (does a box clip render past the
|
|
1414
|
+
* viewport, is clip.y CSS px, do fixed elements repeat per band, are seams pixel-exact) was
|
|
1415
|
+
* measured on Chrome and on nothing else, and Firefox was not launched here. `tile:true` is a
|
|
1416
|
+
* refusal in screenshot() naming --browser chrome; the default (auto) never bands on this
|
|
1417
|
+
* backend, so it is unchanged.
|
|
1229
1418
|
* - emulate.mediaFeatures / emulate.cpuThrottling / emulate.networkConditions: not declared.
|
|
1230
1419
|
* Firefox 153 does not implement setMediaFeaturesOverride; CPU throttling and network condition
|
|
1231
1420
|
* emulation have no verified-working BiDi path in this environment, so emulate() never claims
|
|
@@ -1238,4 +1427,10 @@ export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
|
1238
1427
|
* - LifetimeModel's "MUST re-establish itself transparently if the transport dies" is NOT
|
|
1239
1428
|
* implemented: a dead BiDi socket surfaces as "disconnected" on every subsequent call instead
|
|
1240
1429
|
* of silently starting a new session underneath the caller. See the top-of-file comment.
|
|
1430
|
+
* - Concurrent clients: Chrome happily accepts many CDP sockets against one browser, Firefox
|
|
1431
|
+
* accepts exactly ONE active BiDi session. In attach mode that makes this driver mutually
|
|
1432
|
+
* exclusive with any other BiDi client (a Playwright/geckodriver run, a second cdp-toolkit)
|
|
1433
|
+
* on the same Firefox, and a client killed without session.end leaks the slot until Firefox
|
|
1434
|
+
* restarts. getConnection maps that into an actionable "disconnected" error; there is nothing
|
|
1435
|
+
* to declare as a Capability here because the constraint is per-browser, not per-tool.
|
|
1241
1436
|
* ---------------------------------------------------------------------------- */
|
package/src/bidi/launch.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Firefox launcher for the WebDriver BiDi backend.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
4
|
+
* What THIS module does: it owns full process lifetime for the Firefox instances it starts —
|
|
5
|
+
* launches the binary, holds its pid, and reaps it. That is required because there is no live
|
|
6
|
+
* handoff for the debug port: you cannot RELAUNCH an already-running Firefox with
|
|
7
|
+
* --remote-debugging-port and have it take effect — the relaunch hands off to the running
|
|
8
|
+
* instance and exits silently, opening no port. The flag only takes effect on the ORIGINAL
|
|
9
|
+
* launch of a process, which is why this launcher must be the one to start it. Do not
|
|
10
|
+
* "simplify" this into a relaunch-to-open-a-port helper; that shape cannot work on Firefox and
|
|
11
|
+
* was verified against Firefox 153.0.3.
|
|
12
|
+
*
|
|
13
|
+
* What this does NOT mean: that Firefox "cannot be attached to" at all. A Firefox that was
|
|
14
|
+
* ORIGINALLY launched with --remote-debugging-port — by this module, or by hand by the user —
|
|
15
|
+
* exposes a plain BiDi ws endpoint that a fresh client CAN connect to later, any number of
|
|
16
|
+
* times. That attach path is real, supported, and separate from this file: see backend.ts's
|
|
17
|
+
* `startFirefoxSession({ endpoint })` and `createFirefoxDriverForEndpoint` (bidi/driver.ts),
|
|
18
|
+
* which is exactly how cdp-toolkit connects to a user-launched Firefox to see their real
|
|
19
|
+
* logged-in profile instead of a throwaway one.
|
|
13
20
|
*
|
|
14
21
|
* Zero runtime dependencies: only node:child_process, node:fs, node:net,
|
|
15
22
|
* node:os and node:path.
|
|
@@ -185,8 +192,11 @@ export async function launchFirefox(opts: LaunchFirefoxOptions = {}): Promise<La
|
|
|
185
192
|
/*
|
|
186
193
|
* CDP methods/domains used: none, this module speaks no browser protocol,
|
|
187
194
|
* it only launches the process and confirms the debug port is listening.
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
195
|
+
* This launcher itself has no attach story — it only ever starts fresh processes — but that is
|
|
196
|
+
* a scope limit of THIS FILE, not a Firefox limit: see backend.ts's `startFirefoxSession({
|
|
197
|
+
* endpoint })` / `createFirefoxDriverForEndpoint` (bidi/driver.ts) for the supported attach path,
|
|
198
|
+
* which connects to the ws endpoint of a Firefox that was launched (by this module or by hand)
|
|
199
|
+
* with --remote-debugging-port. Still unsupported, and unsupportable on Firefox, is relaunching
|
|
200
|
+
* the binary against an already-running instance to open a port after the fact (header above) —
|
|
201
|
+
* that is a different operation from attaching to a port that is already open.
|
|
192
202
|
*/
|
package/src/bidi-tools.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { BrowserDriver, InterceptRule, PageDriver, PageInfo } from "./driver.ts";
|
|
11
11
|
import { selectRule, buildFulfillParams, effectiveAction } from "./tools/network_mock.ts";
|
|
12
12
|
import type { TargetSelector } from "./types.ts";
|
|
13
|
+
import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
|
|
13
14
|
|
|
14
15
|
class BidiToolError extends Error {}
|
|
15
16
|
|
|
@@ -33,6 +34,22 @@ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undef
|
|
|
33
34
|
const consoleBuffers = new Map<string, Array<Record<string, unknown>>>();
|
|
34
35
|
const networkBuffers = new Map<string, Array<Record<string, unknown>>>();
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Refuse the Chrome-only `worker:<substring>` arm BEFORE a browser is launched.
|
|
39
|
+
*
|
|
40
|
+
* pickContext already refuses it (bidi/driver.ts), but only after driver.page()
|
|
41
|
+
* has dialed — and on Firefox that means LAUNCHING a browser for a call that
|
|
42
|
+
* cannot possibly succeed. The gate is written as a capability check rather than
|
|
43
|
+
* "this file is the BiDi one" so it stays true if a future BiDi driver ever
|
|
44
|
+
* gains the capability, and the message is the shared one, so the teaching does
|
|
45
|
+
* not fork.
|
|
46
|
+
*/
|
|
47
|
+
function refuseWorkerSelector(driver: BrowserDriver, target: TargetSelector): void {
|
|
48
|
+
if (isWorkerSelector(target) && !driver.capabilities.has("worker.targets")) {
|
|
49
|
+
throw new BidiToolError(WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function bufferFor(map: Map<string, Array<Record<string, unknown>>>, id: string): Array<Record<string, unknown>> {
|
|
37
54
|
let b = map.get(id);
|
|
38
55
|
if (!b) { b = []; map.set(id, b); }
|
|
@@ -59,6 +76,7 @@ export async function listConsoleMessages(
|
|
|
59
76
|
driver: BrowserDriver,
|
|
60
77
|
args: { target?: TargetSelector; reload?: boolean; durationMs?: number } = {},
|
|
61
78
|
): Promise<{ messages: Array<Record<string, unknown>>; count: number }> {
|
|
79
|
+
refuseWorkerSelector(driver, args.target);
|
|
62
80
|
return withPage(driver, args.target, async (page) => {
|
|
63
81
|
if (args.reload) await captureFresh(page, args.durationMs ?? 2500);
|
|
64
82
|
const buf = bufferFor(consoleBuffers, page.info.id);
|
|
@@ -70,6 +88,7 @@ export async function getConsoleMessage(
|
|
|
70
88
|
driver: BrowserDriver,
|
|
71
89
|
args: { target?: TargetSelector; index?: number } = {},
|
|
72
90
|
): Promise<Record<string, unknown>> {
|
|
91
|
+
refuseWorkerSelector(driver, args.target);
|
|
73
92
|
return withPage(driver, args.target, async (page) => {
|
|
74
93
|
const buf = bufferFor(consoleBuffers, page.info.id);
|
|
75
94
|
const idx = args.index ?? 0;
|
|
@@ -83,6 +102,7 @@ export async function listNetworkRequests(
|
|
|
83
102
|
driver: BrowserDriver,
|
|
84
103
|
args: { target?: TargetSelector; reload?: boolean; durationMs?: number; filterUrl?: string } = {},
|
|
85
104
|
): Promise<{ requests: Array<Record<string, unknown>>; count: number }> {
|
|
105
|
+
refuseWorkerSelector(driver, args.target);
|
|
86
106
|
return withPage(driver, args.target, async (page) => {
|
|
87
107
|
if (args.reload) await captureFresh(page, args.durationMs ?? 2500);
|
|
88
108
|
let buf = bufferFor(networkBuffers, page.info.id);
|
|
@@ -95,6 +115,7 @@ export async function getNetworkRequest(
|
|
|
95
115
|
driver: BrowserDriver,
|
|
96
116
|
args: { target?: TargetSelector; requestId?: string; url?: string } = {},
|
|
97
117
|
): Promise<Record<string, unknown>> {
|
|
118
|
+
refuseWorkerSelector(driver, args.target);
|
|
98
119
|
if (!args.requestId && !args.url) throw new BidiToolError("get_network_request requires requestId or url");
|
|
99
120
|
return withPage(driver, args.target, async (page) => {
|
|
100
121
|
const buf = bufferFor(networkBuffers, page.info.id);
|