@roxybrowser/playwright 2.0.2-beta.1 → 2.0.2-beta.12

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.
Files changed (87) hide show
  1. package/README.md +30 -7
  2. package/dist/bin/roxybrowser-mcp.js +3 -0
  3. package/dist/bin/roxybrowser-mcp.js.map +1 -1
  4. package/dist/browser.d.ts +33 -5
  5. package/dist/browser.d.ts.map +1 -1
  6. package/dist/browser.js +134 -15
  7. package/dist/browser.js.map +1 -1
  8. package/dist/browserType.d.ts +23 -1
  9. package/dist/browserType.d.ts.map +1 -1
  10. package/dist/browserType.js +33 -5
  11. package/dist/browserType.js.map +1 -1
  12. package/dist/human/bubbleCursor.d.ts +2 -0
  13. package/dist/human/bubbleCursor.d.ts.map +1 -0
  14. package/dist/human/bubbleCursor.js +126 -0
  15. package/dist/human/bubbleCursor.js.map +1 -0
  16. package/dist/human/controller.d.ts +1 -0
  17. package/dist/human/controller.d.ts.map +1 -1
  18. package/dist/human/controller.js +13 -12
  19. package/dist/human/controller.js.map +1 -1
  20. package/dist/human/profile.d.ts.map +1 -1
  21. package/dist/human/profile.js +0 -4
  22. package/dist/human/profile.js.map +1 -1
  23. package/dist/human/types.d.ts +0 -1
  24. package/dist/human/types.d.ts.map +1 -1
  25. package/dist/locator.js +1 -1
  26. package/dist/locator.js.map +1 -1
  27. package/dist/mcp/backend/connect.d.ts +1 -0
  28. package/dist/mcp/backend/connect.d.ts.map +1 -1
  29. package/dist/mcp/backend/connect.js +4 -2
  30. package/dist/mcp/backend/connect.js.map +1 -1
  31. package/dist/mcp/backend/context.d.ts +3 -0
  32. package/dist/mcp/backend/context.d.ts.map +1 -1
  33. package/dist/mcp/backend/context.js +9 -1
  34. package/dist/mcp/backend/context.js.map +1 -1
  35. package/dist/mcp/backend/keyboard.d.ts +28 -0
  36. package/dist/mcp/backend/keyboard.d.ts.map +1 -1
  37. package/dist/mcp/backend/keyboard.js +9 -3
  38. package/dist/mcp/backend/keyboard.js.map +1 -1
  39. package/dist/mcp/backend/network.d.ts.map +1 -1
  40. package/dist/mcp/backend/network.js +30 -4
  41. package/dist/mcp/backend/network.js.map +1 -1
  42. package/dist/mcp/backend/response.d.ts +2 -0
  43. package/dist/mcp/backend/response.d.ts.map +1 -1
  44. package/dist/mcp/backend/response.js +53 -7
  45. package/dist/mcp/backend/response.js.map +1 -1
  46. package/dist/mcp/connectedBrowser.d.ts.map +1 -1
  47. package/dist/mcp/connectedBrowser.js +492 -43
  48. package/dist/mcp/connectedBrowser.js.map +1 -1
  49. package/dist/mcp/output.d.ts +5 -0
  50. package/dist/mcp/output.d.ts.map +1 -1
  51. package/dist/mcp/output.js +17 -1
  52. package/dist/mcp/output.js.map +1 -1
  53. package/dist/mcp/runtime.d.ts +10 -1
  54. package/dist/mcp/runtime.d.ts.map +1 -1
  55. package/dist/mcp/runtime.js +82 -13
  56. package/dist/mcp/runtime.js.map +1 -1
  57. package/dist/mcp/server.d.ts.map +1 -1
  58. package/dist/mcp/server.js +3 -1
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/mcp/tools/mouse.d.ts.map +1 -1
  61. package/dist/mcp/tools/mouse.js +5 -2
  62. package/dist/mcp/tools/mouse.js.map +1 -1
  63. package/dist/mcp/transports/inMemory.d.ts.map +1 -1
  64. package/dist/mcp/transports/inMemory.js +1 -0
  65. package/dist/mcp/transports/inMemory.js.map +1 -1
  66. package/dist/mcp/types.d.ts +12 -1
  67. package/dist/mcp/types.d.ts.map +1 -1
  68. package/dist/page.d.ts +2 -0
  69. package/dist/page.d.ts.map +1 -1
  70. package/dist/page.js +12 -0
  71. package/dist/page.js.map +1 -1
  72. package/dist/protocol/adapter.d.ts +1 -0
  73. package/dist/protocol/adapter.d.ts.map +1 -1
  74. package/dist/protocol/bidi/backend.d.ts +1 -1
  75. package/dist/protocol/bidi/backend.d.ts.map +1 -1
  76. package/dist/protocol/bidi/backend.js +6 -2
  77. package/dist/protocol/bidi/backend.js.map +1 -1
  78. package/dist/protocol/cdp/backend.d.ts.map +1 -1
  79. package/dist/protocol/cdp/backend.js +122 -8
  80. package/dist/protocol/cdp/backend.js.map +1 -1
  81. package/dist/roxybrowser.bundle.js +560 -68
  82. package/dist/roxybrowser.bundle.js.map +1 -1
  83. package/dist/types/api.d.ts +23 -4
  84. package/dist/types/api.d.ts.map +1 -1
  85. package/dist/types/options.d.ts +0 -1
  86. package/dist/types/options.d.ts.map +1 -1
  87. package/package.json +2 -1
@@ -4,11 +4,29 @@ import * as cdpModule from "chrome-remote-interface";
4
4
  import { normalizeAriaSnapshotOptions, retryUntilReady } from "../ariaSnapshot.js";
5
5
  import { PLAYWRIGHT_ARIA_SNAPSHOT_EVALUATE_SOURCE as ARIA_SNAPSHOT_EVALUATE_SOURCE } from "../vendor/playwright/ariaSnapshotEvaluate.js";
6
6
  import { getBidiClientFactory } from "../protocol/bidi/client.js";
7
+ import { parseSerializedEvaluationResult, wrapWithSerializedEvaluationResult } from "../protocol/evaluationSerializer.js";
8
+ import { BUBBLE_CURSOR_INSTALL_SOURCE } from "../human/bubbleCursor.js";
7
9
  import { McpToolError } from "./errors.js";
8
10
  import { ACTION_POINT_EVALUATE_SOURCE, ACTION_POINT_BY_SELECTOR_SOURCE } from "./snapshot.js";
11
+ import { configuredTempDir } from "./output.js";
9
12
  function delay(ms) {
10
13
  return new Promise((resolve) => setTimeout(resolve, ms));
11
14
  }
15
+ async function withBiDiTimeout(promise, timeoutMs) {
16
+ let timer;
17
+ try {
18
+ return await Promise.race([
19
+ promise,
20
+ new Promise((_, reject) => {
21
+ timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms.`)), timeoutMs);
22
+ })
23
+ ]);
24
+ }
25
+ finally {
26
+ if (timer)
27
+ clearTimeout(timer);
28
+ }
29
+ }
12
30
  const chromeRemoteInterface = ("default" in cdpModule
13
31
  ? cdpModule.default
14
32
  : cdpModule);
@@ -197,6 +215,7 @@ const SCROLL_ELEMENT_SOURCE = String.raw `(payload) => {
197
215
  target.scrollBy({ left: payload.deltaX, top: payload.deltaY, behavior: 'instant' });
198
216
  return { ok: true };
199
217
  }`;
218
+ const ENSURE_BUBBLE_CURSOR_SOURCE = BUBBLE_CURSOR_INSTALL_SOURCE;
200
219
  const GET_ELEMENT_OBJECT_SOURCE = String.raw `(payload) => {
201
220
  const state = globalThis.__roxyMcpState;
202
221
  return payload.nodeToken
@@ -351,7 +370,7 @@ async function evaluateBiDi(client, contextId, functionSource, arg) {
351
370
  ? `(${functionSource})()`
352
371
  : `(${functionSource})(${JSON.stringify(arg)})`;
353
372
  const response = (await client.scriptEvaluate({
354
- expression,
373
+ expression: wrapWithSerializedEvaluationResult(expression),
355
374
  target: {
356
375
  context: contextId
357
376
  },
@@ -361,7 +380,7 @@ async function evaluateBiDi(client, contextId, functionSource, arg) {
361
380
  if (response.type === "exception") {
362
381
  throw new Error(response.exceptionDetails?.text || "BiDi runtime evaluation failed.");
363
382
  }
364
- return response.result?.value;
383
+ return parseSerializedEvaluationResult(extractBiDiValue(response.result));
365
384
  }
366
385
  async function evaluateBiDiRef(client, contextId, functionSource, arg) {
367
386
  const expression = arg === undefined
@@ -389,6 +408,43 @@ async function evaluateBiDiRef(client, contextId, functionSource, arg) {
389
408
  ...(response.result?.value?.handle !== undefined ? { handle: response.result.value.handle } : {})
390
409
  };
391
410
  }
411
+ async function splitScrollDeltas(deltaX, deltaY, stepPx) {
412
+ const dominantDistance = Math.max(Math.abs(deltaX), Math.abs(deltaY));
413
+ if (dominantDistance === 0) {
414
+ return [];
415
+ }
416
+ const steps = Math.max(1, Math.ceil(dominantDistance / Math.max(1, stepPx)));
417
+ const chunks = [];
418
+ let appliedX = 0;
419
+ let appliedY = 0;
420
+ for (let index = 0; index < steps; index += 1) {
421
+ const nextAppliedX = Math.round((deltaX * (index + 1)) / steps);
422
+ const nextAppliedY = Math.round((deltaY * (index + 1)) / steps);
423
+ chunks.push({
424
+ deltaX: nextAppliedX - appliedX,
425
+ deltaY: nextAppliedY - appliedY
426
+ });
427
+ appliedX = nextAppliedX;
428
+ appliedY = nextAppliedY;
429
+ }
430
+ return chunks.filter((chunk) => chunk.deltaX !== 0 || chunk.deltaY !== 0);
431
+ }
432
+ function extractBiDiValue(value) {
433
+ if (!value) {
434
+ return undefined;
435
+ }
436
+ if (value.type === "array" && Array.isArray(value.value)) {
437
+ return value.value.map((entry) => extractBiDiValue(entry));
438
+ }
439
+ if (value.type === "object" && Array.isArray(value.value)) {
440
+ const obj = {};
441
+ for (const [key, val] of value.value) {
442
+ obj[key] = extractBiDiValue(val);
443
+ }
444
+ return obj;
445
+ }
446
+ return value.value;
447
+ }
392
448
  function toAriaSnapshotPayload(request = {}) {
393
449
  return {
394
450
  options: normalizeAriaSnapshotOptions({
@@ -432,11 +488,16 @@ class CdpConnectedBrowserSession {
432
488
  pageConsoleStates = new Map();
433
489
  pageNetworkStates = new Map();
434
490
  pageDialogStates = new Map();
491
+ dialogWaiters = new Map();
435
492
  activeTabId;
436
493
  versionString = "Chromium/unknown";
437
- constructor(browserClient, connection) {
494
+ tempDir;
495
+ constructor(browserClient, connection, tempDir) {
438
496
  this.browserClient = browserClient;
439
497
  this.connection = connection;
498
+ this.tempDir = configuredTempDir({
499
+ ...(tempDir !== undefined ? { tempDir } : {})
500
+ });
440
501
  }
441
502
  static async connect(args) {
442
503
  if (args.browser && args.browser !== "chromium") {
@@ -450,12 +511,9 @@ class CdpConnectedBrowserSession {
450
511
  const browserClient = await chromeRemoteInterface({
451
512
  target: connection.browserWsEndpoint
452
513
  });
453
- const session = new CdpConnectedBrowserSession(browserClient, connection);
514
+ const session = new CdpConnectedBrowserSession(browserClient, connection, args.tempDir);
454
515
  session.versionString = version.Browser;
455
- const tabs = await session.refreshTabs();
456
- if (tabs.length === 0) {
457
- await session.newTab();
458
- }
516
+ await session.refreshTabs();
459
517
  await session.getActivePageClient().catch(() => undefined);
460
518
  return session;
461
519
  }
@@ -519,6 +577,7 @@ class CdpConnectedBrowserSession {
519
577
  async click(target, options) {
520
578
  const pageClient = await this.getActivePageClient();
521
579
  const contextId = await this.getActiveUtilityContextId(pageClient);
580
+ const tabId = await this.getActiveTabId();
522
581
  const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
523
582
  const arg = "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
524
583
  const point = await evaluateCdp(pageClient, source, arg, contextId);
@@ -552,7 +611,7 @@ class CdpConnectedBrowserSession {
552
611
  modifiers: modifiersMask
553
612
  });
554
613
  await delay(options.clickHoldMs);
555
- await pageClient.Input.dispatchMouseEvent({
614
+ const releasePromise = pageClient.Input.dispatchMouseEvent({
556
615
  type: "mouseReleased",
557
616
  x: point.x,
558
617
  y: point.y,
@@ -560,6 +619,10 @@ class CdpConnectedBrowserSession {
560
619
  clickCount,
561
620
  modifiers: modifiersMask
562
621
  });
622
+ await Promise.race([
623
+ releasePromise,
624
+ this.waitForDialog(tabId, options.clickHoldMs + 1000)
625
+ ]);
563
626
  }
564
627
  }
565
628
  async drag(start, end, options) {
@@ -745,11 +808,18 @@ class CdpConnectedBrowserSession {
745
808
  deviceScaleFactor: 1
746
809
  });
747
810
  }
748
- async scroll(target, deltaX, deltaY) {
811
+ async scroll(target, deltaX, deltaY, options) {
749
812
  const pageClient = await this.getActivePageClient();
750
813
  const contextId = await this.getActiveUtilityContextId(pageClient);
751
814
  const arg = target ? this.targetArg(target) : {};
752
- await evaluateCdp(pageClient, SCROLL_ELEMENT_SOURCE, { ...arg, deltaX, deltaY }, contextId);
815
+ await evaluateCdp(pageClient, ENSURE_BUBBLE_CURSOR_SOURCE, undefined, contextId).catch(() => false);
816
+ const chunks = await splitScrollDeltas(deltaX, deltaY, options?.stepPx ?? Math.max(Math.abs(deltaX), Math.abs(deltaY), 1));
817
+ for (const chunk of chunks) {
818
+ await evaluateCdp(pageClient, SCROLL_ELEMENT_SOURCE, { ...arg, deltaX: chunk.deltaX, deltaY: chunk.deltaY }, contextId);
819
+ if ((options?.stepDelayMs ?? 0) > 0) {
820
+ await delay(options.stepDelayMs);
821
+ }
822
+ }
753
823
  }
754
824
  async screenshot(options = {}) {
755
825
  const pageClient = await this.getActivePageClient();
@@ -816,26 +886,105 @@ class CdpConnectedBrowserSession {
816
886
  }
817
887
  }
818
888
  async handleDialog(accept, promptText) {
819
- const tabId = await this.getActiveTabId();
889
+ const tabId = this.dialogTabId();
820
890
  if (!this.pageDialogStates.has(tabId)) {
821
891
  throw new McpToolError("no_dialog", "No dialog visible.");
822
892
  }
823
- const pageClient = await this.getActivePageClient();
893
+ const pageClient = this.pageClients.get(tabId) ?? await this.getActivePageClient();
824
894
  this.pageDialogStates.delete(tabId);
825
895
  await pageClient.Page.handleJavaScriptDialog({
826
896
  accept,
827
897
  ...(promptText !== undefined ? { promptText } : {})
828
898
  });
829
899
  }
900
+ async hasDialog() {
901
+ return this.pageDialogStates.size > 0;
902
+ }
903
+ waitForDialog(tabId, timeoutMs) {
904
+ if (this.pageDialogStates.has(tabId)) {
905
+ return Promise.resolve();
906
+ }
907
+ return new Promise((resolve) => {
908
+ const waiter = {
909
+ resolve: () => {
910
+ if (waiter.timer) {
911
+ clearTimeout(waiter.timer);
912
+ }
913
+ this.removeDialogWaiter(tabId, waiter);
914
+ resolve();
915
+ }
916
+ };
917
+ waiter.timer = setTimeout(() => waiter.resolve(), timeoutMs);
918
+ const waiters = this.dialogWaiters.get(tabId) ?? new Set();
919
+ waiters.add(waiter);
920
+ this.dialogWaiters.set(tabId, waiters);
921
+ });
922
+ }
923
+ resolveDialogWaiters(tabId) {
924
+ const waiters = this.dialogWaiters.get(tabId);
925
+ if (!waiters) {
926
+ return;
927
+ }
928
+ this.dialogWaiters.delete(tabId);
929
+ for (const waiter of waiters) {
930
+ waiter.resolve();
931
+ }
932
+ }
933
+ removeDialogWaiter(tabId, waiter) {
934
+ const waiters = this.dialogWaiters.get(tabId);
935
+ if (!waiters) {
936
+ return;
937
+ }
938
+ waiters.delete(waiter);
939
+ if (waiters.size === 0) {
940
+ this.dialogWaiters.delete(tabId);
941
+ }
942
+ }
830
943
  async networkRequests() {
831
944
  const tabId = await this.getActiveTabId();
945
+ await this.hydratePerformanceResourceRequests(tabId);
832
946
  return this.ensureNetworkState(tabId).requests.map(cloneNetworkRequest);
833
947
  }
834
948
  async networkRequest(index) {
835
949
  const tabId = await this.getActiveTabId();
950
+ await this.hydratePerformanceResourceRequests(tabId);
836
951
  const request = this.ensureNetworkState(tabId).requests[index - 1];
837
952
  return request ? cloneNetworkRequest(request) : undefined;
838
953
  }
954
+ async fetchResponseBody(index) {
955
+ const tabId = await this.getActiveTabId();
956
+ const state = this.ensureNetworkState(tabId);
957
+ const request = state.requests[index - 1];
958
+ if (!request || !request.requestId) {
959
+ return request?.responseBody;
960
+ }
961
+ if (!canReadResponseBody(request)) {
962
+ return undefined;
963
+ }
964
+ if (request.responseBody !== undefined) {
965
+ return request.responseBody;
966
+ }
967
+ await waitForLoadingDone(state, request.requestId, 5_000).catch(() => undefined);
968
+ if (request.responseBody !== undefined) {
969
+ return request.responseBody;
970
+ }
971
+ if (state.bodyRead.has(request.requestId)) {
972
+ return undefined;
973
+ }
974
+ state.bodyRead.add(request.requestId);
975
+ const pageClient = this.pageClients.get(tabId) ?? await this.getActivePageClient();
976
+ const clientNetwork = pageClient.Network;
977
+ if (!clientNetwork) {
978
+ return undefined;
979
+ }
980
+ const body = await clientNetwork.getResponseBody({ requestId: request.requestId }).catch(() => undefined);
981
+ if (body) {
982
+ request.responseBody = body.base64Encoded
983
+ ? Buffer.from(body.body, "base64").toString("utf8")
984
+ : body.body;
985
+ }
986
+ return request.responseBody;
987
+ }
839
988
  async runCodeUnsafe(code) {
840
989
  const pageClient = await this.getActivePageClient();
841
990
  const contextId = await this.getActiveUtilityContextId(pageClient);
@@ -905,6 +1054,16 @@ class CdpConnectedBrowserSession {
905
1054
  }
906
1055
  return activeTab.id;
907
1056
  }
1057
+ dialogTabId() {
1058
+ if (this.activeTabId && this.pageDialogStates.has(this.activeTabId)) {
1059
+ return this.activeTabId;
1060
+ }
1061
+ const tabId = this.pageDialogStates.keys().next().value;
1062
+ if (!tabId) {
1063
+ throw new McpToolError("no_dialog", "No dialog visible.");
1064
+ }
1065
+ return tabId;
1066
+ }
908
1067
  async getActivePageClient() {
909
1068
  const tabs = await this.refreshTabs();
910
1069
  const activeTab = tabs.find((tab) => tab.active);
@@ -999,6 +1158,7 @@ class CdpConnectedBrowserSession {
999
1158
  ...(event.defaultPrompt !== undefined ? { defaultPrompt: event.defaultPrompt } : {}),
1000
1159
  ...(event.url !== undefined ? { url: event.url } : {})
1001
1160
  });
1161
+ this.resolveDialogWaiters(tabId);
1002
1162
  });
1003
1163
  this.installNetworkCollection(tabId, client);
1004
1164
  }
@@ -1046,13 +1206,15 @@ class CdpConnectedBrowserSession {
1046
1206
  client.Network.loadingFinished(async (event) => {
1047
1207
  const request = state.byRequestId.get(event.requestId);
1048
1208
  if (!request) {
1209
+ resolveLoadingDone(state, event.requestId, true);
1049
1210
  return;
1050
1211
  }
1051
1212
  const startedAt = state.startedAt.get(event.requestId);
1052
1213
  if (startedAt !== undefined && event.timestamp !== undefined) {
1053
1214
  request.durationMs = Math.round(event.timestamp * 1000 - startedAt);
1054
1215
  }
1055
- if (canReadResponseBody(request)) {
1216
+ if (canReadResponseBody(request) && !state.bodyRead.has(event.requestId)) {
1217
+ state.bodyRead.add(event.requestId);
1056
1218
  const clientNetwork = client.Network;
1057
1219
  const body = await clientNetwork?.getResponseBody({ requestId: event.requestId }).catch(() => undefined);
1058
1220
  if (body) {
@@ -1061,10 +1223,12 @@ class CdpConnectedBrowserSession {
1061
1223
  : body.body;
1062
1224
  }
1063
1225
  }
1226
+ resolveLoadingDone(state, event.requestId, true);
1064
1227
  });
1065
1228
  client.Network.loadingFailed((event) => {
1066
1229
  const request = state.byRequestId.get(event.requestId);
1067
1230
  if (!request) {
1231
+ resolveLoadingDone(state, event.requestId, false);
1068
1232
  return;
1069
1233
  }
1070
1234
  request.failureText = event.errorText ?? "Unknown error";
@@ -1072,6 +1236,7 @@ class CdpConnectedBrowserSession {
1072
1236
  if (startedAt !== undefined && event.timestamp !== undefined) {
1073
1237
  request.durationMs = Math.round(event.timestamp * 1000 - startedAt);
1074
1238
  }
1239
+ resolveLoadingDone(state, event.requestId, false);
1075
1240
  });
1076
1241
  }
1077
1242
  ensureConsoleState(tabId) {
@@ -1093,7 +1258,10 @@ class CdpConnectedBrowserSession {
1093
1258
  state = {
1094
1259
  requests: [],
1095
1260
  byRequestId: new Map(),
1096
- startedAt: new Map()
1261
+ startedAt: new Map(),
1262
+ hydratedPerformanceResources: false,
1263
+ loadingDone: new Map(),
1264
+ bodyRead: new Set()
1097
1265
  };
1098
1266
  this.pageNetworkStates.set(tabId, state);
1099
1267
  }
@@ -1109,10 +1277,82 @@ class CdpConnectedBrowserSession {
1109
1277
  this.pageNetworkStates.set(tabId, {
1110
1278
  requests: [],
1111
1279
  byRequestId: new Map(),
1112
- startedAt: new Map()
1280
+ startedAt: new Map(),
1281
+ hydratedPerformanceResources: false,
1282
+ loadingDone: new Map(),
1283
+ bodyRead: new Set()
1113
1284
  });
1114
1285
  this.pageDialogStates.delete(tabId);
1115
1286
  }
1287
+ async hydratePerformanceResourceRequests(tabId) {
1288
+ const state = this.ensureNetworkState(tabId);
1289
+ if (state.hydratedPerformanceResources) {
1290
+ return;
1291
+ }
1292
+ state.hydratedPerformanceResources = true;
1293
+ const pageClient = await this.getActivePageClient();
1294
+ const contextId = await this.getActiveUtilityContextId(pageClient);
1295
+ const documentRequest = await evaluateCdp(pageClient, String.raw `() => {
1296
+ const navigation = performance.getEntriesByType("navigation")[0];
1297
+ return {
1298
+ url: String(location.href || ""),
1299
+ duration: navigation ? Math.round(Number(navigation.duration || 0)) : undefined
1300
+ };
1301
+ }`, undefined, contextId).catch(() => undefined);
1302
+ if (documentRequest?.url && !Array.from(state.byRequestId.values()).some((request) => request.url === documentRequest.url)) {
1303
+ const requestId = `performance:navigation:${documentRequest.url}`;
1304
+ const request = {
1305
+ index: state.requests.length + 1,
1306
+ requestId,
1307
+ method: "GET",
1308
+ url: documentRequest.url,
1309
+ resourceType: "document",
1310
+ requestHeaders: {},
1311
+ status: 200,
1312
+ statusText: "OK",
1313
+ ...(documentRequest.duration !== undefined ? { durationMs: documentRequest.duration } : {})
1314
+ };
1315
+ state.requests.push(request);
1316
+ state.byRequestId.set(requestId, request);
1317
+ }
1318
+ const resources = await evaluateCdp(pageClient, String.raw `() => performance.getEntriesByType("resource").map((entry) => ({
1319
+ name: String(entry.name || ""),
1320
+ initiatorType: String(entry.initiatorType || "other"),
1321
+ duration: Math.round(Number(entry.duration || 0)),
1322
+ responseStatus: typeof entry.responseStatus === "number" ? entry.responseStatus : undefined
1323
+ }))`, undefined, contextId).catch(() => []);
1324
+ for (const resource of resources) {
1325
+ if (!resource.name || Array.from(state.byRequestId.values()).some((request) => request.url === resource.name)) {
1326
+ continue;
1327
+ }
1328
+ const status = resource.responseStatus && resource.responseStatus > 0
1329
+ ? resource.responseStatus
1330
+ : await this.probeResourceStatus(pageClient, contextId, resource.name);
1331
+ const requestId = `performance:${resource.name}`;
1332
+ const request = {
1333
+ index: state.requests.length + 1,
1334
+ requestId,
1335
+ method: "GET",
1336
+ url: resource.name,
1337
+ resourceType: normalizeResourceType(resource.initiatorType),
1338
+ requestHeaders: {},
1339
+ ...(status !== undefined ? { status, statusText: statusTextForStatus(status) } : {}),
1340
+ ...(resource.duration !== undefined ? { durationMs: resource.duration } : {})
1341
+ };
1342
+ state.requests.push(request);
1343
+ state.byRequestId.set(requestId, request);
1344
+ }
1345
+ }
1346
+ async probeResourceStatus(pageClient, contextId, url) {
1347
+ return evaluateCdp(pageClient, String.raw `async (url) => {
1348
+ try {
1349
+ const response = await fetch(url, { method: "HEAD", cache: "no-store" });
1350
+ return response.status;
1351
+ } catch {
1352
+ return undefined;
1353
+ }
1354
+ }`, url, contextId).catch(() => undefined);
1355
+ }
1116
1356
  addConsoleMessage(tabId, message) {
1117
1357
  const state = this.ensureConsoleState(tabId);
1118
1358
  if (!shouldIncludeConsoleMessage(message.type)) {
@@ -1181,7 +1421,7 @@ class CdpConnectedBrowserSession {
1181
1421
  if (messages.length === 0) {
1182
1422
  return undefined;
1183
1423
  }
1184
- state.logFile ??= path.join(process.cwd(), ".playwright-mcp", `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
1424
+ state.logFile ??= path.join(this.tempDir, `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
1185
1425
  await mkdir(path.dirname(state.logFile), { recursive: true });
1186
1426
  const fromLine = state.logLine + 1;
1187
1427
  for (const message of messages) {
@@ -1191,9 +1431,8 @@ class CdpConnectedBrowserSession {
1191
1431
  state.logLine += logLine.split("\n").length - 1;
1192
1432
  }
1193
1433
  state.nextMessageIndex = state.messages.length;
1194
- const relativePath = path.relative(process.cwd(), state.logFile);
1195
1434
  const lineRange = fromLine === state.logLine ? `#L${fromLine}` : `#L${fromLine}-L${state.logLine}`;
1196
- return `${relativePath}${lineRange}`;
1435
+ return `${state.logFile}${lineRange}`;
1197
1436
  }
1198
1437
  }
1199
1438
  async function waitForCdpDocumentReady(client, timeoutMs) {
@@ -1282,6 +1521,59 @@ function canReadResponseBody(request) {
1282
1521
  }
1283
1522
  return request.status !== 204 && request.status !== 304 && !(request.status >= 100 && request.status < 200);
1284
1523
  }
1524
+ function loadingDoneEntry(state, requestId) {
1525
+ let entry = state.loadingDone.get(requestId);
1526
+ if (!entry) {
1527
+ let resolve;
1528
+ let reject;
1529
+ const promise = new Promise((res, rej) => {
1530
+ resolve = res;
1531
+ reject = rej;
1532
+ });
1533
+ entry = { promise, resolve, reject };
1534
+ state.loadingDone.set(requestId, entry);
1535
+ }
1536
+ return entry;
1537
+ }
1538
+ function resolveLoadingDone(state, requestId, success) {
1539
+ const entry = state.loadingDone.get(requestId);
1540
+ if (!entry) {
1541
+ return;
1542
+ }
1543
+ state.loadingDone.delete(requestId);
1544
+ if (success) {
1545
+ entry.resolve();
1546
+ }
1547
+ else {
1548
+ entry.reject(new Error("Request failed before the response body was available."));
1549
+ }
1550
+ }
1551
+ async function waitForLoadingDone(state, requestId, timeoutMs) {
1552
+ const entry = loadingDoneEntry(state, requestId);
1553
+ await Promise.race([
1554
+ entry.promise,
1555
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
1556
+ ]);
1557
+ }
1558
+ function statusTextForStatus(status) {
1559
+ if (status === 200)
1560
+ return "OK";
1561
+ if (status === 204)
1562
+ return "No Content";
1563
+ if (status === 304)
1564
+ return "Not Modified";
1565
+ if (status === 400)
1566
+ return "Bad Request";
1567
+ if (status === 401)
1568
+ return "Unauthorized";
1569
+ if (status === 403)
1570
+ return "Forbidden";
1571
+ if (status === 404)
1572
+ return "Not Found";
1573
+ if (status === 500)
1574
+ return "Internal Server Error";
1575
+ return "";
1576
+ }
1285
1577
  function cloneNetworkRequest(request) {
1286
1578
  return {
1287
1579
  ...request,
@@ -1381,12 +1673,17 @@ class BidiConnectedBrowserSession {
1381
1673
  pageConsoleStates = new Map();
1382
1674
  pageNetworkStates = new Map();
1383
1675
  pageDialogStates = new Map();
1676
+ dialogWaiters = new Map();
1384
1677
  bidiListeners = new Map();
1385
1678
  responseDataCollector;
1386
1679
  activeTabId;
1387
1680
  ownsSession = false;
1388
- constructor(client) {
1681
+ tempDir;
1682
+ constructor(client, tempDir) {
1389
1683
  this.client = client;
1684
+ this.tempDir = configuredTempDir({
1685
+ ...(tempDir !== undefined ? { tempDir } : {})
1686
+ });
1390
1687
  }
1391
1688
  static async connect(args) {
1392
1689
  if (args.browser && args.browser !== "firefox") {
@@ -1398,15 +1695,12 @@ class BidiConnectedBrowserSession {
1398
1695
  }
1399
1696
  const client = await getBidiClientFactory()({
1400
1697
  browserName: "firefox",
1401
- webSocketUrl: normalizeFirefoxBidiEndpoint(args.endpoint)
1698
+ webSocketUrl: normalizeFirefoxBidiEndpoint(args.endpoint, args.sessionId)
1402
1699
  });
1403
- const session = new BidiConnectedBrowserSession(client);
1404
- session.ownsSession = await ensureMcpBiDiSession(client, args.endpoint);
1700
+ const session = new BidiConnectedBrowserSession(client, args.tempDir);
1701
+ session.ownsSession = await ensureMcpBiDiSession(client, args.endpoint, args.sessionId);
1405
1702
  await session.initialize();
1406
- const tabs = await session.refreshTabs();
1407
- if (tabs.length === 0) {
1408
- await session.newTab();
1409
- }
1703
+ await session.refreshTabs();
1410
1704
  return session;
1411
1705
  }
1412
1706
  async version() {
@@ -1465,10 +1759,14 @@ class BidiConnectedBrowserSession {
1465
1759
  async snapshot(request = {}) {
1466
1760
  const tabId = await this.getActiveTabId();
1467
1761
  const result = await retryUntilReady(() => evaluateBiDi(this.client, tabId, ARIA_SNAPSHOT_EVALUATE_SOURCE, toAriaSnapshotPayload(request)));
1468
- return toBrowserSnapshot(result, request, {
1762
+ const snapshot = toBrowserSnapshot(result, request, {
1469
1763
  console: this.consoleSummary(tabId),
1470
1764
  consoleLink: await this.takeConsoleLink(tabId)
1471
1765
  });
1766
+ return {
1767
+ ...snapshot,
1768
+ retryable: true
1769
+ };
1472
1770
  }
1473
1771
  async consoleMessages(level = "info", all = false) {
1474
1772
  const activeTabId = await this.getActiveTabId();
@@ -1558,10 +1856,28 @@ class BidiConnectedBrowserSession {
1558
1856
  parameters: { pointerType: "mouse" },
1559
1857
  actions: pointerActions
1560
1858
  });
1561
- await this.client.inputPerformActions({
1859
+ // TODO(bidi): A synchronous alert()/confirm()/prompt() opened by the click
1860
+ // blocks the page's main thread, and in Firefox that also wedges the BiDi
1861
+ // transport: inputPerformActions never resolves while the modal is open.
1862
+ // Unlike CDP (where only the mouse-release call blocks), BiDi dispatches
1863
+ // the whole pointer sequence as one atomic command, so we cannot split it.
1864
+ // Mitigation (NOT a full fix): race the action against the dialog waiter so
1865
+ // a dialog-opening click resolves instead of hanging forever. The residual
1866
+ // performPromise is intentionally left dangling; it resolves later once the
1867
+ // dialog is dismissed.
1868
+ //
1869
+ // KNOWN-UNRESOLVED: even with this race,后续的 BiDi 命令在模态框打开期间仍可能
1870
+ // 整体卡死(见 handleDialog 的 TODO)。Firefox/geckodriver 在 alert 模态下会
1871
+ // 阻塞几乎所有 BiDi 命令,这是浏览器/驱动层的限制,本适配器无法绕过。
1872
+ const performPromise = this.client.inputPerformActions({
1562
1873
  context: tabId,
1563
1874
  actions
1564
1875
  });
1876
+ await Promise.race([
1877
+ performPromise,
1878
+ this.waitForDialog(tabId, options.clickHoldMs + 5000)
1879
+ ]);
1880
+ performPromise.catch(() => { });
1565
1881
  await this.client.inputReleaseActions({ context: tabId }).catch(() => { });
1566
1882
  }
1567
1883
  async drag(start, end, options) {
@@ -1746,10 +2062,21 @@ class BidiConnectedBrowserSession {
1746
2062
  viewport: { width, height }
1747
2063
  });
1748
2064
  }
1749
- async scroll(target, deltaX, deltaY) {
2065
+ async scroll(target, deltaX, deltaY, options) {
1750
2066
  const tabId = await this.getActiveTabId();
1751
2067
  const arg = target ? ("nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector }) : {};
1752
- await evaluateBiDi(this.client, tabId, SCROLL_ELEMENT_SOURCE, { ...arg, deltaX, deltaY });
2068
+ await evaluateBiDi(this.client, tabId, ENSURE_BUBBLE_CURSOR_SOURCE).catch(() => false);
2069
+ const chunks = await splitScrollDeltas(deltaX, deltaY, options?.stepPx ?? Math.max(Math.abs(deltaX), Math.abs(deltaY), 1));
2070
+ for (const chunk of chunks) {
2071
+ await evaluateBiDi(this.client, tabId, SCROLL_ELEMENT_SOURCE, {
2072
+ ...arg,
2073
+ deltaX: chunk.deltaX,
2074
+ deltaY: chunk.deltaY
2075
+ });
2076
+ if ((options?.stepDelayMs ?? 0) > 0) {
2077
+ await delay(options.stepDelayMs);
2078
+ }
2079
+ }
1753
2080
  }
1754
2081
  async screenshot(options = {}) {
1755
2082
  const tabId = await this.getActiveTabId();
@@ -1812,16 +2139,27 @@ class BidiConnectedBrowserSession {
1812
2139
  }
1813
2140
  }
1814
2141
  async handleDialog(accept, promptText) {
1815
- const tabId = await this.getActiveTabId();
2142
+ const tabId = this.dialogTabId();
1816
2143
  if (!this.pageDialogStates.has(tabId)) {
1817
2144
  throw new McpToolError("no_dialog", "No dialog visible.");
1818
2145
  }
1819
2146
  this.pageDialogStates.delete(tabId);
1820
- await this.client.browsingContextHandleUserPrompt({
2147
+ // TODO(bidi): Firefox's browsingContext.handleUserPrompt can stall while a
2148
+ // modal is open — 实测在 alert/confirm 模态下 geckodriver 对该命令的响应会
2149
+ // 长时间不返回(实测 60s+ 不返回,最终靠 MCP 客户端超时才解脱)。这里用
2150
+ // withBiDiTimeout 兜底:最多等 5s 后强制 reject,避免工具调用无限挂起。
2151
+ // 这只是“快速失败”的缓解,并未真正解决“模态框打开时几乎所有 BiDi 命令都
2152
+ // 被卡死”的底层问题。CDP 下这一路径是可靠的,BiDi 暂只能参考 CDP 思路。
2153
+ // KNOWN-UNRESOLVED: 若在模态框打开期间调用本命令,前序的 refreshTabs
2154
+ // (browsingContextGetTree) 等也可能先一步卡死,导致整个 tool 调用超时。
2155
+ await withBiDiTimeout(this.client.browsingContextHandleUserPrompt({
1821
2156
  context: tabId,
1822
2157
  accept,
1823
2158
  ...(promptText !== undefined ? { userText: promptText } : {})
1824
- });
2159
+ }), 5_000);
2160
+ }
2161
+ async hasDialog() {
2162
+ return this.pageDialogStates.size > 0;
1825
2163
  }
1826
2164
  async networkRequests() {
1827
2165
  const tabId = await this.getActiveTabId();
@@ -1832,6 +2170,21 @@ class BidiConnectedBrowserSession {
1832
2170
  const request = this.ensureNetworkState(tabId).requests[index - 1];
1833
2171
  return request ? cloneNetworkRequest(request) : undefined;
1834
2172
  }
2173
+ async fetchResponseBody(index) {
2174
+ const tabId = await this.getActiveTabId();
2175
+ const request = this.ensureNetworkState(tabId).requests[index - 1];
2176
+ if (!request || !request.requestId) {
2177
+ return request?.responseBody;
2178
+ }
2179
+ if (request.responseBody !== undefined) {
2180
+ return request.responseBody;
2181
+ }
2182
+ const body = await this.getResponseBody(request.requestId).catch(() => undefined);
2183
+ if (body !== undefined) {
2184
+ request.responseBody = body;
2185
+ }
2186
+ return request.responseBody;
2187
+ }
1835
2188
  async runCodeUnsafe(code) {
1836
2189
  return this.evaluate(`async () => {
1837
2190
  const fn = eval(${JSON.stringify(`(${code})`)});
@@ -1847,6 +2200,15 @@ class BidiConnectedBrowserSession {
1847
2200
  }`);
1848
2201
  }
1849
2202
  async initialize() {
2203
+ // 顺序很关键:必须先 attachBiDiListeners(),再 sessionSubscribe()。
2204
+ // 实测 Firefox:sessionSubscribe 返回后事件会立即开始涌入;若此刻监听器
2205
+ // 还没注册,最早的一批 network.beforeRequestSent(页面导航/资源请求)会落进
2206
+ // “no registered listener” 分支被静默丢弃,导致 network 列表里缺首页请求。
2207
+ // 调试时观察到 [DEBUG bidi client no-listener] network.beforeRequestSent 连续
2208
+ // 出现几十次,正是此问题。先注册监听器即可避免丢事件。
2209
+ // TODO(bidi): 即便修了顺序,BiDi 网络捕获仍有状态时序问题,见
2210
+ // handleResponseCompleted / networkRequests 的 TODO。
2211
+ this.attachBiDiListeners();
1850
2212
  await this.client.sessionSubscribe({
1851
2213
  events: [
1852
2214
  "browsingContext.userPromptOpened",
@@ -1862,7 +2224,6 @@ class BidiConnectedBrowserSession {
1862
2224
  maxEncodedDataSize: 10_000_000
1863
2225
  }).catch(() => undefined);
1864
2226
  this.responseDataCollector = collectorResult?.collector;
1865
- this.attachBiDiListeners();
1866
2227
  }
1867
2228
  attachBiDiListeners() {
1868
2229
  this.attachBiDiListener("log.entryAdded", (payload) => this.handleLogEntry(payload));
@@ -1904,6 +2265,63 @@ class BidiConnectedBrowserSession {
1904
2265
  targetArg(target) {
1905
2266
  return "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
1906
2267
  }
2268
+ dialogTabId() {
2269
+ if (this.activeTabId && this.pageDialogStates.has(this.activeTabId)) {
2270
+ return this.activeTabId;
2271
+ }
2272
+ const tabId = this.pageDialogStates.keys().next().value;
2273
+ if (!tabId) {
2274
+ throw new McpToolError("no_dialog", "No dialog visible.");
2275
+ }
2276
+ return tabId;
2277
+ }
2278
+ waitForDialog(tabId, timeoutMs) {
2279
+ if (this.pageDialogStates.has(tabId)) {
2280
+ return Promise.resolve();
2281
+ }
2282
+ return new Promise((resolve, reject) => {
2283
+ const waiter = {
2284
+ resolve: () => {
2285
+ if (waiter.timer) {
2286
+ clearTimeout(waiter.timer);
2287
+ }
2288
+ this.removeDialogWaiter(tabId, waiter);
2289
+ resolve();
2290
+ },
2291
+ reject: (error) => {
2292
+ if (waiter.timer) {
2293
+ clearTimeout(waiter.timer);
2294
+ }
2295
+ this.removeDialogWaiter(tabId, waiter);
2296
+ reject(error);
2297
+ }
2298
+ };
2299
+ waiter.timer = setTimeout(() => waiter.reject?.(new Error("Timed out waiting for dialog.")), timeoutMs);
2300
+ const waiters = this.dialogWaiters.get(tabId) ?? new Set();
2301
+ waiters.add(waiter);
2302
+ this.dialogWaiters.set(tabId, waiters);
2303
+ });
2304
+ }
2305
+ resolveDialogWaiters(tabId) {
2306
+ const waiters = this.dialogWaiters.get(tabId);
2307
+ if (!waiters) {
2308
+ return;
2309
+ }
2310
+ this.dialogWaiters.delete(tabId);
2311
+ for (const waiter of waiters) {
2312
+ waiter.resolve();
2313
+ }
2314
+ }
2315
+ removeDialogWaiter(tabId, waiter) {
2316
+ const waiters = this.dialogWaiters.get(tabId);
2317
+ if (!waiters) {
2318
+ return;
2319
+ }
2320
+ waiters.delete(waiter);
2321
+ if (waiters.size === 0) {
2322
+ this.dialogWaiters.delete(tabId);
2323
+ }
2324
+ }
1907
2325
  async actionPoint(tabId, target) {
1908
2326
  const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
1909
2327
  const point = await evaluateBiDi(this.client, tabId, source, this.targetArg(target));
@@ -1964,6 +2382,11 @@ class BidiConnectedBrowserSession {
1964
2382
  type: event.type ?? "alert",
1965
2383
  ...(event.defaultValue !== undefined ? { defaultPrompt: event.defaultValue } : {})
1966
2384
  });
2385
+ // 这里必须 resolve 对话框等待器:BiDi 的 click 会 race inputPerformActions
2386
+ // 与 waitForDialog(见 click 注释)。若不在此 resolve,alert() 触发后
2387
+ // waitForDialog 会一直 pending,click 永久挂起。CDP 侧的
2388
+ // javascriptDialogOpening 处理也调用了 resolveDialogWaiters,两侧需对齐。
2389
+ this.resolveDialogWaiters(event.context);
1967
2390
  }
1968
2391
  handleBeforeRequestSent(payload) {
1969
2392
  const event = parseBidiNetworkEvent(payload);
@@ -1984,6 +2407,12 @@ class BidiConnectedBrowserSession {
1984
2407
  request.url = event.request.url;
1985
2408
  request.resourceType = normalizeResourceType(event.request.destination);
1986
2409
  request.requestHeaders = normalizeHeaders(bidiHeadersToRecord(event.request.headers));
2410
+ // TODO(bidi): BiDi 的 network.beforeRequestSent 只给 bodySize,不内联 POST
2411
+ // body。这里只置了个空串占位(requestBody ??= ""),真实 POST 体从未填充,
2412
+ // 因此 browser_network_request 的 part="request-body" 在 BiDi 下只能拿到空串。
2413
+ // CDP 侧通过 Network.requestWillBeSent 的 request.postData 直接拿到 body。
2414
+ // BiDi 若要拿到 body 需另发 network.getRequestPostData 请求(Firefox 支持不稳),
2415
+ // 暂未实现 —— 这是已知缺口,等 geckodriver 稳定后再补。
1987
2416
  if (event.request.bodySize !== undefined && event.request.bodySize > 0) {
1988
2417
  request.requestBody ??= "";
1989
2418
  }
@@ -2020,6 +2449,17 @@ class BidiConnectedBrowserSession {
2020
2449
  if (startedAt !== undefined && event.timestamp !== undefined) {
2021
2450
  request.durationMs = Math.round(event.timestamp - startedAt);
2022
2451
  }
2452
+ // TODO(bidi): BiDi 网络事件是乱序/延迟到达的,且 status 与 body 的可用时机
2453
+ // 不可靠。实测 Firefox:
2454
+ // - beforeRequestSent 与 responseCompleted 之间存在竞态,waitForNetworkRequest
2455
+ // 在 body/status 尚未就绪时就可能匹配到请求并返回;
2456
+ // - 随后再次 browser_network_requests 时,同一个 POST /api 请求有时会从列表
2457
+ // 里“消失”(疑似 responseCompleted 到达途中 ensureNetworkRequest 重建了条目
2458
+ // 或上下文切换所致,未完全定位)。
2459
+ // 这导致 BiDi 下无法像 CDP 那样做强一致的网络契约断言(=> [status] OK 全匹配)。
2460
+ // 这里仅在 responseCompleted 时尽力补 body;status 缺失的窗口由调用方容忍。
2461
+ // KNOWN-UNRESOLVED: BiDi 网络捕获的一致性问题,需等 Firefox/geckodriver 事件
2462
+ // 时序稳定后再追,或改用 network.getDataCollector 统一采集。
2023
2463
  if (canReadResponseBody(request)) {
2024
2464
  const body = await this.getResponseBody(event.request.request).catch(() => undefined);
2025
2465
  if (body !== undefined) {
@@ -2086,7 +2526,10 @@ class BidiConnectedBrowserSession {
2086
2526
  state = {
2087
2527
  requests: [],
2088
2528
  byRequestId: new Map(),
2089
- startedAt: new Map()
2529
+ startedAt: new Map(),
2530
+ hydratedPerformanceResources: false,
2531
+ loadingDone: new Map(),
2532
+ bodyRead: new Set()
2090
2533
  };
2091
2534
  this.pageNetworkStates.set(tabId, state);
2092
2535
  }
@@ -2102,7 +2545,10 @@ class BidiConnectedBrowserSession {
2102
2545
  this.pageNetworkStates.set(tabId, {
2103
2546
  requests: [],
2104
2547
  byRequestId: new Map(),
2105
- startedAt: new Map()
2548
+ startedAt: new Map(),
2549
+ hydratedPerformanceResources: false,
2550
+ loadingDone: new Map(),
2551
+ bodyRead: new Set()
2106
2552
  });
2107
2553
  this.pageDialogStates.delete(tabId);
2108
2554
  }
@@ -2133,7 +2579,7 @@ class BidiConnectedBrowserSession {
2133
2579
  if (messages.length === 0) {
2134
2580
  return undefined;
2135
2581
  }
2136
- state.logFile ??= path.join(process.cwd(), ".playwright-mcp", `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
2582
+ state.logFile ??= path.join(this.tempDir, `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
2137
2583
  await mkdir(path.dirname(state.logFile), { recursive: true });
2138
2584
  const fromLine = state.logLine + 1;
2139
2585
  for (const message of messages) {
@@ -2143,21 +2589,24 @@ class BidiConnectedBrowserSession {
2143
2589
  state.logLine += logLine.split("\n").length - 1;
2144
2590
  }
2145
2591
  state.nextMessageIndex = state.messages.length;
2146
- const relativePath = path.relative(process.cwd(), state.logFile);
2147
2592
  const lineRange = fromLine === state.logLine ? `#L${fromLine}` : `#L${fromLine}-L${state.logLine}`;
2148
- return `${relativePath}${lineRange}`;
2593
+ return `${state.logFile}${lineRange}`;
2149
2594
  }
2150
2595
  }
2151
- function normalizeFirefoxBidiEndpoint(endpoint) {
2596
+ function normalizeFirefoxBidiEndpoint(endpoint, sessionId) {
2152
2597
  const url = new URL(endpoint);
2598
+ if (sessionId) {
2599
+ url.pathname = `/session/${sessionId}`;
2600
+ return url.toString();
2601
+ }
2153
2602
  if (url.pathname === "/" || url.pathname === "") {
2154
2603
  url.pathname = "/session";
2155
2604
  }
2156
2605
  return url.toString();
2157
2606
  }
2158
- async function ensureMcpBiDiSession(client, endpoint) {
2607
+ async function ensureMcpBiDiSession(client, endpoint, sessionId) {
2159
2608
  await client.sessionStatus({});
2160
- if (isSessionSpecificFirefoxBidiEndpoint(endpoint)) {
2609
+ if (sessionId || isSessionSpecificFirefoxBidiEndpoint(endpoint)) {
2161
2610
  return false;
2162
2611
  }
2163
2612
  try {