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.
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * BiDi implementation of the browser-neutral Driver contract in ../driver.ts (ADR-001), for
3
- * Firefox. lifetime is "session": a BiDi socket cannot be resumed, so createFirefoxDriver(port)
4
- * memoizes ONE BidiConnection per port at module scope (the connections map below), page()
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. There is deliberately no reconnect-and-resume path: if the socket dies, every pending
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, connectBidiSession } from "./client.ts";
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 port, shared by every page() acquisition on that
298
- // port. getConnection retains a holder on every call (fresh or reused); callers release() it.
299
- const connections = new Map<number, BidiConnection>();
300
- async function getConnection(port: number, timeoutMs?: number): Promise<BidiConnection> {
301
- const existing = connections.get(port);
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 connectBidiSession(port, {
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(port, conn);
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 port:context ->
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 port above and
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
- async screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }> {
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
- constructor(private readonly port: number, private readonly timeoutMs?: number) {}
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs).catch(() => undefined);
1281
+ const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
1128
1282
  if (!conn) return false;
1129
1283
  try {
1130
- const key = `${this.port}:${contextId}`;
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.port, this.timeoutMs).catch(() => undefined);
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.port}:`)) bidiBeaconScripts.delete(key);
1328
+ if (key.startsWith(`${this.endpoint}:`)) bidiBeaconScripts.delete(key);
1175
1329
  }
1176
- const conn = connections.get(this.port);
1330
+ const conn = connections.get(this.endpoint);
1177
1331
  if (!conn) return;
1178
- connections.delete(this.port);
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. `port` is a Firefox debug
1184
- * port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's convention. */
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, opts?.timeoutMs);
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); browsingContext
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
  * ---------------------------------------------------------------------------- */
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * Firefox launcher for the WebDriver BiDi backend.
3
3
  *
4
- * Why this exists at all: unlike Chrome, there is no attach-to-the-user's-
5
- * running-browser story here. You cannot add --remote-debugging-port to an
6
- * already-running Firefox: relaunching the binary with that flag hands off
7
- * to the running instance and exits silently, opening no port. The flag
8
- * only takes effect on the ORIGINAL launch of a process. That means this
9
- * module must own full process lifetime, start it, own its pid, and reap
10
- * it, because there is no later point at which a debug port can be bolted
11
- * on. Do not "simplify" this into an attach-to-running-browser helper; that
12
- * shape cannot work on Firefox and was verified against Firefox 153.0.3.
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
- * Parity gap vs the Chrome-side story: there is no equivalent of attaching
189
- * to an already-running browser instance the way client.ts can resolve a
190
- * target on an already-listening port 9222. Firefox always requires this
191
- * launcher to have started the process.
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
  */