cdp-toolkit 1.9.1 → 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.
@@ -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,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 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);
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 connectBidiSession(port, {
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(port, conn);
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 port:context ->
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 port above and
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
- async screenshot(opts?: ScreenshotOptions): Promise<{ data: Uint8Array; format: "png" | "jpeg" }> {
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
- constructor(private readonly port: number, private readonly timeoutMs?: number) {}
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs);
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.port, this.timeoutMs).catch(() => undefined);
1280
+ const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
1128
1281
  if (!conn) return false;
1129
1282
  try {
1130
- const key = `${this.port}:${contextId}`;
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.port, this.timeoutMs).catch(() => undefined);
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.port}:`)) bidiBeaconScripts.delete(key);
1327
+ if (key.startsWith(`${this.endpoint}:`)) bidiBeaconScripts.delete(key);
1175
1328
  }
1176
- const conn = connections.get(this.port);
1329
+ const conn = connections.get(this.endpoint);
1177
1330
  if (!conn) return;
1178
- connections.delete(this.port);
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. `port` is a Firefox debug
1184
- * port already listening (see ./launch.ts's launchFirefox), never 9222/CDP's convention. */
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, opts?.timeoutMs);
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); browsingContext
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
  * ---------------------------------------------------------------------------- */
@@ -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
  */