cdp-toolkit 1.9.1 → 1.9.5
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 +434 -0
- package/CONTRACT.md +59 -2
- package/README.md +68 -3
- package/dist/cli.js +2857 -114
- package/dist/index.js +934 -78
- package/dist/mcp.js +1130 -115
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +230 -33
- package/src/bidi/launch.ts +23 -13
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +107 -10
- package/src/driver.ts +254 -3
- package/src/manifest.ts +20 -4
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
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,65 @@ 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 stays undeclared, but no longer for an unmeasured reason: live-fired against
|
|
305
|
+
* Firefox 153.0.3, a 20,000px-tall page captured with take_screenshot --fullPage true came back
|
|
306
|
+
* as a single 1366x20000 PNG at scale 1, verified complete top-to-bottom — no clip, no bands.
|
|
307
|
+
* Chrome's band tiling exists solely to route around its 16384-device-px encode cap; Firefox has
|
|
308
|
+
* no such cap, so screenshot.tile (the capability gating explicit tile:true forced banding) has
|
|
309
|
+
* no gap here to close. It stays undeclared because it would advertise a forced-tiling primitive
|
|
310
|
+
* this backend has never needed, not because band-tiling behavior is untested.
|
|
291
311
|
*/
|
|
292
312
|
const BIDI_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
293
|
-
"emulate.deviceMetrics", "screenshot.fullPage", "screenshot.element", "network.intercept", "locate.xpath",
|
|
313
|
+
"emulate.deviceMetrics", "screenshot.fullPage", "screenshot.element", "screenshot.renderSize", "network.intercept", "locate.xpath",
|
|
294
314
|
]);
|
|
295
315
|
|
|
296
316
|
/* --------------------------------- module-scope session memoization --------------------------------- */
|
|
297
|
-
// "session" LifetimeModel: ONE BidiConnection per
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
317
|
+
// "session" LifetimeModel: ONE BidiConnection per BiDi ws endpoint, shared by every page()
|
|
318
|
+
// acquisition on that endpoint. Keyed by the endpoint URL string rather than a port because attach
|
|
319
|
+
// mode's endpoint may be any host/port/path (../backend.ts's normalizeBidiEndpoint), and the URL is
|
|
320
|
+
// the only identity both modes share. getConnection retains a holder on every call (fresh or
|
|
321
|
+
// reused); callers release() it.
|
|
322
|
+
const connections = new Map<string, BidiConnection>();
|
|
323
|
+
async function getConnection(endpoint: string, timeoutMs?: number): Promise<BidiConnection> {
|
|
324
|
+
const existing = connections.get(endpoint);
|
|
302
325
|
if (existing) { existing.retain(); return existing; }
|
|
303
326
|
// Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
|
|
304
327
|
// gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
|
|
305
328
|
// reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
|
|
306
329
|
// next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
|
|
307
330
|
// and answer a live prompt at all.
|
|
308
|
-
const conn = await
|
|
331
|
+
const conn = await connectBidiSessionUrl(endpoint, {
|
|
309
332
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
310
333
|
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } },
|
|
334
|
+
}).catch((e: unknown) => {
|
|
335
|
+
// Firefox allows exactly ONE active BiDi session and never reaps an abandoned one (verified:
|
|
336
|
+
// still held 10s after an abrupt socket death). A client killed without session.end therefore
|
|
337
|
+
// leaves the slot occupied until Firefox restarts, and the raw wire message ("Maximum number of
|
|
338
|
+
// active sessions") tells the user nothing about how to get unstuck. Force-ending a session we
|
|
339
|
+
// do not own is not an option (there is no id to end), so this maps the error to instructions.
|
|
340
|
+
if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
|
|
341
|
+
throw driverError("disconnected",
|
|
342
|
+
`Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` +
|
|
343
|
+
`Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` +
|
|
344
|
+
`session that Firefox does not reap.)`);
|
|
345
|
+
}
|
|
346
|
+
throw e;
|
|
311
347
|
}); // already retained once
|
|
312
|
-
connections.set(
|
|
348
|
+
connections.set(endpoint, conn);
|
|
313
349
|
return conn;
|
|
314
350
|
}
|
|
315
351
|
|
|
316
352
|
/**
|
|
317
|
-
* Contexts that already carry a beacon preload script, keyed
|
|
353
|
+
* Contexts that already carry a beacon preload script, keyed endpoint:context ->
|
|
318
354
|
* the script id BiDi handed back.
|
|
319
355
|
*
|
|
320
356
|
* WHY FIREFOX NEEDS NO HELD CONNECTION where Chrome does. This driver's
|
|
321
|
-
* LifetimeModel is "session": ONE BidiConnection is memoized per
|
|
357
|
+
* LifetimeModel is "session": ONE BidiConnection is memoized per endpoint above and
|
|
322
358
|
* lives until dispose(), so a preload script registered on it stays registered
|
|
323
359
|
* across navigations for free. Chrome's equivalent registration is cleared when
|
|
324
360
|
* the registering client disconnects, and this toolkit's CDP lifetime is
|
|
@@ -771,7 +807,100 @@ class BidiPageDriver implements PageDriver {
|
|
|
771
807
|
}
|
|
772
808
|
}
|
|
773
809
|
|
|
774
|
-
|
|
810
|
+
/**
|
|
811
|
+
* A capture, optionally rendered at an emulated viewport that is put back afterwards. Same
|
|
812
|
+
* contract and the same restore-on-every-exit discipline as CdpPageDriver.screenshot — read that
|
|
813
|
+
* method's comment for the reasoning; only the commands differ (browsingContext.setViewport here,
|
|
814
|
+
* Emulation.setDeviceMetricsOverride there).
|
|
815
|
+
*
|
|
816
|
+
* NOT LIVE-FIRED. Firefox was not launched for this change, so unlike the Chrome path every claim
|
|
817
|
+
* here rests on the shape of browsingContext.setViewport plus the unit tests, not on a measured
|
|
818
|
+
* page. What IS verified is that the restore cannot be skipped: the ordering and the finally-path
|
|
819
|
+
* are shared logic exercised by test/screenshot-scale.test.ts against a stub. Two specifics to
|
|
820
|
+
* confirm when Firefox is next driven: whether setViewport's null viewport reverts to the real
|
|
821
|
+
* window (CDP's clear does not, for an override another session set), and whether it reflows
|
|
822
|
+
* synchronously the way setDeviceMetricsOverride was measured to.
|
|
823
|
+
*/
|
|
824
|
+
async screenshot<T>(opts?: ScreenshotOptions, _consumeBands?: BandConsumer<T>): Promise<CaptureDelivery<T>> {
|
|
825
|
+
const renderSize =
|
|
826
|
+
opts?.renderWidth !== undefined && opts?.renderHeight !== undefined
|
|
827
|
+
? { width: opts.renderWidth, height: opts.renderHeight }
|
|
828
|
+
: undefined;
|
|
829
|
+
if (!renderSize) return this.capture(opts);
|
|
830
|
+
const previous = await readViewportRecord(this.browser.scheme, this.contextId);
|
|
831
|
+
try {
|
|
832
|
+
// devicePixelRatio is deliberately not sent: this changes the render SIZE, and omitting the
|
|
833
|
+
// field leaves whatever ratio an earlier emulate set in place (the CDP path inherits it the
|
|
834
|
+
// same way).
|
|
835
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: renderSize.width, height: renderSize.height } });
|
|
836
|
+
} catch (e) {
|
|
837
|
+
throw mapBidiError(e);
|
|
838
|
+
}
|
|
839
|
+
let shot: ScreenshotResult | undefined;
|
|
840
|
+
let captureError: unknown;
|
|
841
|
+
try {
|
|
842
|
+
shot = await this.capture(opts);
|
|
843
|
+
} catch (e) {
|
|
844
|
+
captureError = e;
|
|
845
|
+
}
|
|
846
|
+
let restoreError: string | undefined;
|
|
847
|
+
try {
|
|
848
|
+
// Re-apply the tracked viewport, never a bare null: a null viewport reverts to the real
|
|
849
|
+
// window and would destroy an override the caller set with emulate earlier.
|
|
850
|
+
if (previous) {
|
|
851
|
+
await this.conn.send("browsingContext.setViewport", {
|
|
852
|
+
context: this.contextId,
|
|
853
|
+
viewport: { width: previous.width, height: previous.height },
|
|
854
|
+
...(previous.deviceScaleFactor != null ? { devicePixelRatio: previous.deviceScaleFactor } : {}),
|
|
855
|
+
});
|
|
856
|
+
} else {
|
|
857
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null });
|
|
858
|
+
}
|
|
859
|
+
} catch (e) {
|
|
860
|
+
restoreError = e instanceof Error ? e.message : String(e);
|
|
861
|
+
}
|
|
862
|
+
if (!shot) {
|
|
863
|
+
if (restoreError !== undefined && captureError instanceof Error) {
|
|
864
|
+
captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
|
|
865
|
+
}
|
|
866
|
+
throw captureError;
|
|
867
|
+
}
|
|
868
|
+
const result: ScreenshotResult = { ...shot, renderSize, renderRestored: restoreError === undefined };
|
|
869
|
+
if (restoreError !== undefined) result.renderRestoreError = restoreError;
|
|
870
|
+
return result;
|
|
871
|
+
}
|
|
872
|
+
/** The capture itself, with no viewport emulation of its own. Split out so screenshot()'s restore
|
|
873
|
+
* wraps EVERY way this can fail, the throwing paths included. */
|
|
874
|
+
private async capture(opts?: ScreenshotOptions): Promise<ScreenshotResult> {
|
|
875
|
+
// ADR-001 param-level gap, same shape as drag's mode:"html5" (see resolveDragMode in
|
|
876
|
+
// ../shared-tools.ts): browsingContext.captureScreenshot has NO scale parameter — its only
|
|
877
|
+
// knobs are origin, format and clip — so a scale asked for here is refused outright rather
|
|
878
|
+
// than silently captured at 1x, which would hand back an image the caller never asked for.
|
|
879
|
+
if (opts?.scale !== undefined && opts.scale !== 1) {
|
|
880
|
+
throw driverError(
|
|
881
|
+
"unsupported",
|
|
882
|
+
`screenshot scale ${opts.scale} is not supported by this backend (WebDriver BiDi's ` +
|
|
883
|
+
"browsingContext.captureScreenshot has no scale parameter). Call emulate with " +
|
|
884
|
+
`deviceScaleFactor:${opts.scale} first and capture at scale 1, or use --browser chrome.`,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
// Same ADR-001 param-level shape, but now a measured non-gap rather than an unmeasured one.
|
|
888
|
+
// Live-fired against Firefox 153.0.3: a 20,000px-tall page captured with take_screenshot
|
|
889
|
+
// --fullPage true came back as one 1366x20000 PNG at scale 1, verified complete top-to-bottom,
|
|
890
|
+
// via the single origin:"document" capture below — no clip, no bands. Chrome's band tiling
|
|
891
|
+
// exists only to route around its 16384-device-px encode cap; this backend has no such cap, so
|
|
892
|
+
// there is nothing for tile:true's forced banding to do here. The refusal below stays because
|
|
893
|
+
// there is no forced-tiling primitive to build, not because it is untested. `tile:false` and
|
|
894
|
+
// the default (auto, which never bands here) ask this backend for nothing and are untouched.
|
|
895
|
+
if (opts?.tile === true) {
|
|
896
|
+
throw driverError(
|
|
897
|
+
"unsupported",
|
|
898
|
+
"screenshot tile:true is not supported by this backend: band tiling is declared only where the band " +
|
|
899
|
+
"arithmetic has been measured against a real browser, and Firefox was not driven for it " +
|
|
900
|
+
'(browsingContext.captureScreenshot\'s clip:{type:"box"} is the primitive it would ride, untested). ' +
|
|
901
|
+
"Capture a smaller region or a single element, or use --browser chrome for a tiled capture.",
|
|
902
|
+
);
|
|
903
|
+
}
|
|
775
904
|
const format = opts?.format ?? "png";
|
|
776
905
|
const params: BrowsingContextCaptureScreenshotParameters = {
|
|
777
906
|
context: this.contextId,
|
|
@@ -792,14 +921,37 @@ class BidiPageDriver implements PageDriver {
|
|
|
792
921
|
// a real parity gap noted in the footer rather than smoothed over.
|
|
793
922
|
async emulate(opts: EmulationOptions): Promise<{ applied: string[] }> {
|
|
794
923
|
if (opts.clearOverrides) {
|
|
924
|
+
// CHECKED FOR THE SAME CROSS-PROCESS NO-OP THE CDP DRIVER HAD (see that method's comment) —
|
|
925
|
+
// by protocol semantics, since Firefox was not launched to measure this directly. The W3C
|
|
926
|
+
// WebDriver BiDi spec's "remote end steps for browsingContext.setViewport" (and the "set
|
|
927
|
+
// viewport" / "set device pixel ratio override" algorithms it calls) key EVERY step off
|
|
928
|
+
// |navigable| — the browsing context — never off which session or connection issued an
|
|
929
|
+
// earlier override: "If |viewport| is not null, set the width/height of |navigable|'s layout
|
|
930
|
+
// viewport... Otherwise, set the |navigable|'s layout viewport to the implementation-defined
|
|
931
|
+
// default", and the device-pixel-ratio twin literally does `Remove |navigable| from [device
|
|
932
|
+
// pixel ratio overrides]`. There is no per-session ownership check anywhere in that algorithm,
|
|
933
|
+
// unlike CDP's clearDeviceMetricsOverride (measured to be a no-op unless the calling
|
|
934
|
+
// connection is also the one that set the override). So a `viewport: null` from ANY BiDi
|
|
935
|
+
// connection reverts THIS navigable to its real default, regardless of which connection —
|
|
936
|
+
// this toolkit's own per-call ones included — set the override being cleared. No defect,
|
|
937
|
+
// no "set-then-clear" workaround needed; left as it was.
|
|
795
938
|
await this.conn.send("emulation.setUserAgentOverride", { userAgent: null, contexts: [this.contextId] }).catch(() => undefined);
|
|
796
939
|
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null, devicePixelRatio: null }).catch(() => undefined);
|
|
940
|
+
// No override of ours left to put back: a render-size capture should restore to null.
|
|
941
|
+
await clearViewportRecord(this.browser.scheme, this.contextId);
|
|
797
942
|
return { applied: [] };
|
|
798
943
|
}
|
|
799
944
|
const applied: string[] = [];
|
|
800
945
|
try {
|
|
801
946
|
if (opts.width != null && opts.height != null) {
|
|
802
947
|
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: opts.width, height: opts.height }, ...(opts.deviceScaleFactor != null ? { devicePixelRatio: opts.deviceScaleFactor } : {}) });
|
|
948
|
+
// Recorded so a later render-size capture puts THIS back rather than reverting to the real
|
|
949
|
+
// window. See ViewportRecord in ../driver.ts.
|
|
950
|
+
await writeViewportRecord(this.browser.scheme, this.contextId, {
|
|
951
|
+
width: opts.width,
|
|
952
|
+
height: opts.height,
|
|
953
|
+
...(opts.deviceScaleFactor != null ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
|
|
954
|
+
});
|
|
803
955
|
applied.push("deviceMetrics");
|
|
804
956
|
}
|
|
805
957
|
if (opts.userAgent != null) {
|
|
@@ -1053,10 +1205,12 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1053
1205
|
readonly capabilities = BIDI_CAPABILITIES;
|
|
1054
1206
|
readonly uidStability: UidStability = "document-stamp";
|
|
1055
1207
|
readonly snapshotFidelity = "dom-heuristic" as const;
|
|
1056
|
-
|
|
1208
|
+
/** `endpoint` is a BiDi ws URL (see getConnection): derived from a port for a launched
|
|
1209
|
+
* Firefox, user-supplied for an attached one. It is this driver's whole identity. */
|
|
1210
|
+
constructor(private readonly endpoint: string, private readonly timeoutMs?: number) {}
|
|
1057
1211
|
|
|
1058
1212
|
async listPages(): Promise<PageInfo[]> {
|
|
1059
|
-
const conn = await getConnection(this.
|
|
1213
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1060
1214
|
try {
|
|
1061
1215
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
|
|
1062
1216
|
return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
|
|
@@ -1065,7 +1219,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1065
1219
|
}
|
|
1066
1220
|
}
|
|
1067
1221
|
async newPage(url?: string): Promise<PageInfo> {
|
|
1068
|
-
const conn = await getConnection(this.
|
|
1222
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1069
1223
|
try {
|
|
1070
1224
|
const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => { throw mapBidiError(e); });
|
|
1071
1225
|
// Established fact: navigating the default about:home context to a data: URL is rejected,
|
|
@@ -1077,7 +1231,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1077
1231
|
}
|
|
1078
1232
|
}
|
|
1079
1233
|
async closePage(id: string): Promise<{ success: boolean }> {
|
|
1080
|
-
const conn = await getConnection(this.
|
|
1234
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1081
1235
|
try {
|
|
1082
1236
|
await conn.send("browsingContext.close", { context: id }).catch((e) => { throw mapBidiError(e); });
|
|
1083
1237
|
return { success: true };
|
|
@@ -1086,7 +1240,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1086
1240
|
}
|
|
1087
1241
|
}
|
|
1088
1242
|
async activatePage(id: string): Promise<PageInfo> {
|
|
1089
|
-
const conn = await getConnection(this.
|
|
1243
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1090
1244
|
try {
|
|
1091
1245
|
await conn.send("browsingContext.activate", { context: id }).catch((e) => { throw mapBidiError(e); });
|
|
1092
1246
|
const ctx = await resolveContext(conn, id);
|
|
@@ -1096,7 +1250,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1096
1250
|
}
|
|
1097
1251
|
}
|
|
1098
1252
|
async page(selector: TargetSelector): Promise<PageDriver> {
|
|
1099
|
-
const conn = await getConnection(this.
|
|
1253
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1100
1254
|
let ctx: BrowsingContextInfo;
|
|
1101
1255
|
try {
|
|
1102
1256
|
ctx = await resolveContext(conn, selector);
|
|
@@ -1124,10 +1278,10 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1124
1278
|
* an annotation that must never fail real work.
|
|
1125
1279
|
*/
|
|
1126
1280
|
async installActivityBeacon(contextId: string): Promise<boolean> {
|
|
1127
|
-
const conn = await getConnection(this.
|
|
1281
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
|
|
1128
1282
|
if (!conn) return false;
|
|
1129
1283
|
try {
|
|
1130
|
-
const key = `${this.
|
|
1284
|
+
const key = `${this.endpoint}:${contextId}`;
|
|
1131
1285
|
if (!bidiBeaconScripts.has(key)) {
|
|
1132
1286
|
const registered = await conn.send("script.addPreloadScript", {
|
|
1133
1287
|
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
@@ -1151,7 +1305,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1151
1305
|
/** Read the beacon timestamp for a context, or null when it has none. Gate-free;
|
|
1152
1306
|
* see the declaration in ../driver.ts for why that is required here. */
|
|
1153
1307
|
async readActivityBeacon(contextId: string): Promise<number | null> {
|
|
1154
|
-
const conn = await getConnection(this.
|
|
1308
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
|
|
1155
1309
|
if (!conn) return null;
|
|
1156
1310
|
try {
|
|
1157
1311
|
const res = await conn.send("script.evaluate", {
|
|
@@ -1171,19 +1325,35 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1171
1325
|
|
|
1172
1326
|
async dispose(): Promise<void> {
|
|
1173
1327
|
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
1174
|
-
if (key.startsWith(`${this.
|
|
1328
|
+
if (key.startsWith(`${this.endpoint}:`)) bidiBeaconScripts.delete(key);
|
|
1175
1329
|
}
|
|
1176
|
-
const conn = connections.get(this.
|
|
1330
|
+
const conn = connections.get(this.endpoint);
|
|
1177
1331
|
if (!conn) return;
|
|
1178
|
-
connections.delete(this.
|
|
1332
|
+
connections.delete(this.endpoint);
|
|
1333
|
+
// Cleanly END the BiDi session so Firefox frees its single session slot for the next
|
|
1334
|
+
// attach (a launch-mode Firefox is killed anyway; this matters for attach mode where the
|
|
1335
|
+
// user's browser lives on). Verified: closing the socket alone does NOT free the slot, and
|
|
1336
|
+
// Firefox never reaps the orphan, so skipping this would make the SECOND attach fail.
|
|
1337
|
+
// Best-effort: harmless if the socket is already gone.
|
|
1338
|
+
await conn.send("session.end", {}).catch(() => undefined);
|
|
1179
1339
|
conn.dispose();
|
|
1180
1340
|
}
|
|
1181
1341
|
}
|
|
1182
1342
|
|
|
1183
|
-
/** Construct the BiDi implementation of the frozen Driver contract
|
|
1184
|
-
* port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's
|
|
1343
|
+
/** Construct the BiDi implementation of the frozen Driver contract for a LAUNCHED Firefox. `port`
|
|
1344
|
+
* is a Firefox debug port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's
|
|
1345
|
+
* convention; it is only ever a shorthand for the loopback endpoint below. Note port 0 is a legal
|
|
1346
|
+
* argument for capability probes (createFirefoxDriver(0)) that never open a socket. */
|
|
1185
1347
|
export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
1186
|
-
return new BidiBrowserDriver(port
|
|
1348
|
+
return new BidiBrowserDriver(`ws://127.0.0.1:${port}/session`, opts?.timeoutMs);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** Construct the same driver for ATTACH mode: `wsUrl` is the BiDi endpoint of a Firefox the user
|
|
1352
|
+
* started themselves with --remote-debugging-port, already normalized by ../backend.ts's
|
|
1353
|
+
* normalizeBidiEndpoint. This driver NEVER owns that process — dispose() ends the BiDi session and
|
|
1354
|
+
* closes the socket, and nothing anywhere in this path kills a browser it did not launch. */
|
|
1355
|
+
export function createFirefoxDriverForEndpoint(wsUrl: string, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
1356
|
+
return new BidiBrowserDriver(wsUrl, opts?.timeoutMs);
|
|
1187
1357
|
}
|
|
1188
1358
|
// resolveContext is exported for ONE reason: it is the Firefox lease choke
|
|
1189
1359
|
// point, and a choke point nothing can call directly is a choke point nothing
|
|
@@ -1192,7 +1362,8 @@ export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number })
|
|
|
1192
1362
|
export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
1193
1363
|
|
|
1194
1364
|
/* ------------------------------------------------------------------------------
|
|
1195
|
-
* BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts
|
|
1365
|
+
* BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts; end, sent by
|
|
1366
|
+
* dispose() so an attached Firefox gets its one session slot back); browsingContext
|
|
1196
1367
|
* (create, close, activate, getTree, navigate, reload, locateNodes, captureScreenshot, setViewport,
|
|
1197
1368
|
* handleUserPrompt); script (evaluate, callFunction); input (performActions, setFiles); network
|
|
1198
1369
|
* (addDataCollector, removeDataCollector, getData, addIntercept, removeIntercept, continueRequest,
|
|
@@ -1226,6 +1397,26 @@ export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
|
1226
1397
|
* this one. Neither insertText route synthesizes a <br> or a block break.
|
|
1227
1398
|
* - trace.performance / heap.snapshot / audit.lighthouse: not declared, Chrome-only domains with
|
|
1228
1399
|
* no BiDi equivalent at all.
|
|
1400
|
+
* - screenshot.scale: not declared. browsingContext.captureScreenshot's parameters are context,
|
|
1401
|
+
* origin, format and clip — no scale anywhere, and its clip types (box/element) carry no
|
|
1402
|
+
* multiplier either. A `scale` other than 1 is a refusal in screenshot() (ADR-001 param-level
|
|
1403
|
+
* gap, like drag's html5 mode), pointing at emulate's deviceScaleFactor as the real workaround.
|
|
1404
|
+
* - screenshot.renderSize: DECLARED, and the one screenshot capability here that is not a gap. It
|
|
1405
|
+
* rides browsingContext.setViewport — the same command emulate.deviceMetrics already uses —
|
|
1406
|
+
* applied for one capture and restored to the tracked viewport (or `viewport:null`) afterwards.
|
|
1407
|
+
* NOT live-fired: Firefox was not launched for this change, so the ordering and the restore rest
|
|
1408
|
+
* on the shared logic's unit tests rather than a measured page. Two things to confirm when
|
|
1409
|
+
* Firefox is next driven: whether a null viewport reverts to the real window (CDP's clear does
|
|
1410
|
+
* NOT, for an override another session set), and whether setViewport reflows synchronously the
|
|
1411
|
+
* way Emulation.setDeviceMetricsOverride was measured to on Chrome.
|
|
1412
|
+
* - screenshot.tile: not declared, but no longer an unmeasured gap. Live-fired against Firefox
|
|
1413
|
+
* 153.0.3: take_screenshot --fullPage true on a 20,000px-tall page returned one 1366x20000 PNG
|
|
1414
|
+
* at scale 1, verified complete top-to-bottom, via a single origin:"document" capture — no
|
|
1415
|
+
* clip, no bands, no cap hit. Chrome's band tiling exists solely to route around its
|
|
1416
|
+
* 16384-device-px encode cap; Firefox has no such cap, so screenshot.tile (forced tiling via
|
|
1417
|
+
* tile:true) has nothing to do here. `tile:true` is still a refusal in screenshot() naming
|
|
1418
|
+
* --browser chrome; the default (auto) still never bands on this backend — both unchanged, now
|
|
1419
|
+
* for a documented reason instead of an untested one.
|
|
1229
1420
|
* - emulate.mediaFeatures / emulate.cpuThrottling / emulate.networkConditions: not declared.
|
|
1230
1421
|
* Firefox 153 does not implement setMediaFeaturesOverride; CPU throttling and network condition
|
|
1231
1422
|
* emulation have no verified-working BiDi path in this environment, so emulate() never claims
|
|
@@ -1238,4 +1429,10 @@ export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
|
1238
1429
|
* - LifetimeModel's "MUST re-establish itself transparently if the transport dies" is NOT
|
|
1239
1430
|
* implemented: a dead BiDi socket surfaces as "disconnected" on every subsequent call instead
|
|
1240
1431
|
* of silently starting a new session underneath the caller. See the top-of-file comment.
|
|
1432
|
+
* - Concurrent clients: Chrome happily accepts many CDP sockets against one browser, Firefox
|
|
1433
|
+
* accepts exactly ONE active BiDi session. In attach mode that makes this driver mutually
|
|
1434
|
+
* exclusive with any other BiDi client (a Playwright/geckodriver run, a second cdp-toolkit)
|
|
1435
|
+
* on the same Firefox, and a client killed without session.end leaks the slot until Firefox
|
|
1436
|
+
* restarts. getConnection maps that into an actionable "disconnected" error; there is nothing
|
|
1437
|
+
* to declare as a Capability here because the constraint is per-browser, not per-tool.
|
|
1241
1438
|
* ---------------------------------------------------------------------------- */
|
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
|
*/
|