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

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 (63) 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/mcp/backend/connect.d.ts +1 -0
  13. package/dist/mcp/backend/connect.d.ts.map +1 -1
  14. package/dist/mcp/backend/connect.js +4 -2
  15. package/dist/mcp/backend/connect.js.map +1 -1
  16. package/dist/mcp/backend/context.d.ts +3 -0
  17. package/dist/mcp/backend/context.d.ts.map +1 -1
  18. package/dist/mcp/backend/context.js +9 -1
  19. package/dist/mcp/backend/context.js.map +1 -1
  20. package/dist/mcp/backend/network.d.ts.map +1 -1
  21. package/dist/mcp/backend/network.js +30 -4
  22. package/dist/mcp/backend/network.js.map +1 -1
  23. package/dist/mcp/backend/response.d.ts +2 -0
  24. package/dist/mcp/backend/response.d.ts.map +1 -1
  25. package/dist/mcp/backend/response.js +53 -7
  26. package/dist/mcp/backend/response.js.map +1 -1
  27. package/dist/mcp/connectedBrowser.d.ts.map +1 -1
  28. package/dist/mcp/connectedBrowser.js +447 -39
  29. package/dist/mcp/connectedBrowser.js.map +1 -1
  30. package/dist/mcp/output.d.ts +5 -0
  31. package/dist/mcp/output.d.ts.map +1 -1
  32. package/dist/mcp/output.js +17 -1
  33. package/dist/mcp/output.js.map +1 -1
  34. package/dist/mcp/runtime.d.ts +7 -0
  35. package/dist/mcp/runtime.d.ts.map +1 -1
  36. package/dist/mcp/runtime.js +76 -11
  37. package/dist/mcp/runtime.js.map +1 -1
  38. package/dist/mcp/server.d.ts.map +1 -1
  39. package/dist/mcp/server.js +3 -1
  40. package/dist/mcp/server.js.map +1 -1
  41. package/dist/mcp/transports/inMemory.d.ts.map +1 -1
  42. package/dist/mcp/transports/inMemory.js +1 -0
  43. package/dist/mcp/transports/inMemory.js.map +1 -1
  44. package/dist/mcp/types.d.ts +7 -0
  45. package/dist/mcp/types.d.ts.map +1 -1
  46. package/dist/page.d.ts +2 -0
  47. package/dist/page.d.ts.map +1 -1
  48. package/dist/page.js +12 -0
  49. package/dist/page.js.map +1 -1
  50. package/dist/protocol/adapter.d.ts +1 -0
  51. package/dist/protocol/adapter.d.ts.map +1 -1
  52. package/dist/protocol/bidi/backend.d.ts +1 -1
  53. package/dist/protocol/bidi/backend.d.ts.map +1 -1
  54. package/dist/protocol/bidi/backend.js +6 -2
  55. package/dist/protocol/bidi/backend.js.map +1 -1
  56. package/dist/protocol/cdp/backend.d.ts.map +1 -1
  57. package/dist/protocol/cdp/backend.js +122 -8
  58. package/dist/protocol/cdp/backend.js.map +1 -1
  59. package/dist/roxybrowser.bundle.js +560 -68
  60. package/dist/roxybrowser.bundle.js.map +1 -1
  61. package/dist/types/api.d.ts +23 -4
  62. package/dist/types/api.d.ts.map +1 -1
  63. package/package.json +2 -1
@@ -4,11 +4,28 @@ 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";
7
8
  import { McpToolError } from "./errors.js";
8
9
  import { ACTION_POINT_EVALUATE_SOURCE, ACTION_POINT_BY_SELECTOR_SOURCE } from "./snapshot.js";
10
+ import { configuredTempDir } from "./output.js";
9
11
  function delay(ms) {
10
12
  return new Promise((resolve) => setTimeout(resolve, ms));
11
13
  }
14
+ async function withBiDiTimeout(promise, timeoutMs) {
15
+ let timer;
16
+ try {
17
+ return await Promise.race([
18
+ promise,
19
+ new Promise((_, reject) => {
20
+ timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms.`)), timeoutMs);
21
+ })
22
+ ]);
23
+ }
24
+ finally {
25
+ if (timer)
26
+ clearTimeout(timer);
27
+ }
28
+ }
12
29
  const chromeRemoteInterface = ("default" in cdpModule
13
30
  ? cdpModule.default
14
31
  : cdpModule);
@@ -351,7 +368,7 @@ async function evaluateBiDi(client, contextId, functionSource, arg) {
351
368
  ? `(${functionSource})()`
352
369
  : `(${functionSource})(${JSON.stringify(arg)})`;
353
370
  const response = (await client.scriptEvaluate({
354
- expression,
371
+ expression: wrapWithSerializedEvaluationResult(expression),
355
372
  target: {
356
373
  context: contextId
357
374
  },
@@ -361,7 +378,7 @@ async function evaluateBiDi(client, contextId, functionSource, arg) {
361
378
  if (response.type === "exception") {
362
379
  throw new Error(response.exceptionDetails?.text || "BiDi runtime evaluation failed.");
363
380
  }
364
- return response.result?.value;
381
+ return parseSerializedEvaluationResult(extractBiDiValue(response.result));
365
382
  }
366
383
  async function evaluateBiDiRef(client, contextId, functionSource, arg) {
367
384
  const expression = arg === undefined
@@ -389,6 +406,22 @@ async function evaluateBiDiRef(client, contextId, functionSource, arg) {
389
406
  ...(response.result?.value?.handle !== undefined ? { handle: response.result.value.handle } : {})
390
407
  };
391
408
  }
409
+ function extractBiDiValue(value) {
410
+ if (!value) {
411
+ return undefined;
412
+ }
413
+ if (value.type === "array" && Array.isArray(value.value)) {
414
+ return value.value.map((entry) => extractBiDiValue(entry));
415
+ }
416
+ if (value.type === "object" && Array.isArray(value.value)) {
417
+ const obj = {};
418
+ for (const [key, val] of value.value) {
419
+ obj[key] = extractBiDiValue(val);
420
+ }
421
+ return obj;
422
+ }
423
+ return value.value;
424
+ }
392
425
  function toAriaSnapshotPayload(request = {}) {
393
426
  return {
394
427
  options: normalizeAriaSnapshotOptions({
@@ -432,11 +465,16 @@ class CdpConnectedBrowserSession {
432
465
  pageConsoleStates = new Map();
433
466
  pageNetworkStates = new Map();
434
467
  pageDialogStates = new Map();
468
+ dialogWaiters = new Map();
435
469
  activeTabId;
436
470
  versionString = "Chromium/unknown";
437
- constructor(browserClient, connection) {
471
+ tempDir;
472
+ constructor(browserClient, connection, tempDir) {
438
473
  this.browserClient = browserClient;
439
474
  this.connection = connection;
475
+ this.tempDir = configuredTempDir({
476
+ ...(tempDir !== undefined ? { tempDir } : {})
477
+ });
440
478
  }
441
479
  static async connect(args) {
442
480
  if (args.browser && args.browser !== "chromium") {
@@ -450,12 +488,9 @@ class CdpConnectedBrowserSession {
450
488
  const browserClient = await chromeRemoteInterface({
451
489
  target: connection.browserWsEndpoint
452
490
  });
453
- const session = new CdpConnectedBrowserSession(browserClient, connection);
491
+ const session = new CdpConnectedBrowserSession(browserClient, connection, args.tempDir);
454
492
  session.versionString = version.Browser;
455
- const tabs = await session.refreshTabs();
456
- if (tabs.length === 0) {
457
- await session.newTab();
458
- }
493
+ await session.refreshTabs();
459
494
  await session.getActivePageClient().catch(() => undefined);
460
495
  return session;
461
496
  }
@@ -519,6 +554,7 @@ class CdpConnectedBrowserSession {
519
554
  async click(target, options) {
520
555
  const pageClient = await this.getActivePageClient();
521
556
  const contextId = await this.getActiveUtilityContextId(pageClient);
557
+ const tabId = await this.getActiveTabId();
522
558
  const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
523
559
  const arg = "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
524
560
  const point = await evaluateCdp(pageClient, source, arg, contextId);
@@ -552,7 +588,7 @@ class CdpConnectedBrowserSession {
552
588
  modifiers: modifiersMask
553
589
  });
554
590
  await delay(options.clickHoldMs);
555
- await pageClient.Input.dispatchMouseEvent({
591
+ const releasePromise = pageClient.Input.dispatchMouseEvent({
556
592
  type: "mouseReleased",
557
593
  x: point.x,
558
594
  y: point.y,
@@ -560,6 +596,10 @@ class CdpConnectedBrowserSession {
560
596
  clickCount,
561
597
  modifiers: modifiersMask
562
598
  });
599
+ await Promise.race([
600
+ releasePromise,
601
+ this.waitForDialog(tabId, options.clickHoldMs + 1000)
602
+ ]);
563
603
  }
564
604
  }
565
605
  async drag(start, end, options) {
@@ -816,26 +856,105 @@ class CdpConnectedBrowserSession {
816
856
  }
817
857
  }
818
858
  async handleDialog(accept, promptText) {
819
- const tabId = await this.getActiveTabId();
859
+ const tabId = this.dialogTabId();
820
860
  if (!this.pageDialogStates.has(tabId)) {
821
861
  throw new McpToolError("no_dialog", "No dialog visible.");
822
862
  }
823
- const pageClient = await this.getActivePageClient();
863
+ const pageClient = this.pageClients.get(tabId) ?? await this.getActivePageClient();
824
864
  this.pageDialogStates.delete(tabId);
825
865
  await pageClient.Page.handleJavaScriptDialog({
826
866
  accept,
827
867
  ...(promptText !== undefined ? { promptText } : {})
828
868
  });
829
869
  }
870
+ async hasDialog() {
871
+ return this.pageDialogStates.size > 0;
872
+ }
873
+ waitForDialog(tabId, timeoutMs) {
874
+ if (this.pageDialogStates.has(tabId)) {
875
+ return Promise.resolve();
876
+ }
877
+ return new Promise((resolve) => {
878
+ const waiter = {
879
+ resolve: () => {
880
+ if (waiter.timer) {
881
+ clearTimeout(waiter.timer);
882
+ }
883
+ this.removeDialogWaiter(tabId, waiter);
884
+ resolve();
885
+ }
886
+ };
887
+ waiter.timer = setTimeout(() => waiter.resolve(), timeoutMs);
888
+ const waiters = this.dialogWaiters.get(tabId) ?? new Set();
889
+ waiters.add(waiter);
890
+ this.dialogWaiters.set(tabId, waiters);
891
+ });
892
+ }
893
+ resolveDialogWaiters(tabId) {
894
+ const waiters = this.dialogWaiters.get(tabId);
895
+ if (!waiters) {
896
+ return;
897
+ }
898
+ this.dialogWaiters.delete(tabId);
899
+ for (const waiter of waiters) {
900
+ waiter.resolve();
901
+ }
902
+ }
903
+ removeDialogWaiter(tabId, waiter) {
904
+ const waiters = this.dialogWaiters.get(tabId);
905
+ if (!waiters) {
906
+ return;
907
+ }
908
+ waiters.delete(waiter);
909
+ if (waiters.size === 0) {
910
+ this.dialogWaiters.delete(tabId);
911
+ }
912
+ }
830
913
  async networkRequests() {
831
914
  const tabId = await this.getActiveTabId();
915
+ await this.hydratePerformanceResourceRequests(tabId);
832
916
  return this.ensureNetworkState(tabId).requests.map(cloneNetworkRequest);
833
917
  }
834
918
  async networkRequest(index) {
835
919
  const tabId = await this.getActiveTabId();
920
+ await this.hydratePerformanceResourceRequests(tabId);
836
921
  const request = this.ensureNetworkState(tabId).requests[index - 1];
837
922
  return request ? cloneNetworkRequest(request) : undefined;
838
923
  }
924
+ async fetchResponseBody(index) {
925
+ const tabId = await this.getActiveTabId();
926
+ const state = this.ensureNetworkState(tabId);
927
+ const request = state.requests[index - 1];
928
+ if (!request || !request.requestId) {
929
+ return request?.responseBody;
930
+ }
931
+ if (!canReadResponseBody(request)) {
932
+ return undefined;
933
+ }
934
+ if (request.responseBody !== undefined) {
935
+ return request.responseBody;
936
+ }
937
+ await waitForLoadingDone(state, request.requestId, 5_000).catch(() => undefined);
938
+ if (request.responseBody !== undefined) {
939
+ return request.responseBody;
940
+ }
941
+ if (state.bodyRead.has(request.requestId)) {
942
+ return undefined;
943
+ }
944
+ state.bodyRead.add(request.requestId);
945
+ const pageClient = this.pageClients.get(tabId) ?? await this.getActivePageClient();
946
+ const clientNetwork = pageClient.Network;
947
+ if (!clientNetwork) {
948
+ return undefined;
949
+ }
950
+ const body = await clientNetwork.getResponseBody({ requestId: request.requestId }).catch(() => undefined);
951
+ if (body) {
952
+ request.responseBody = body.base64Encoded
953
+ ? Buffer.from(body.body, "base64").toString("utf8")
954
+ : body.body;
955
+ }
956
+ return request.responseBody;
957
+ }
839
958
  async runCodeUnsafe(code) {
840
959
  const pageClient = await this.getActivePageClient();
841
960
  const contextId = await this.getActiveUtilityContextId(pageClient);
@@ -905,6 +1024,16 @@ class CdpConnectedBrowserSession {
905
1024
  }
906
1025
  return activeTab.id;
907
1026
  }
1027
+ dialogTabId() {
1028
+ if (this.activeTabId && this.pageDialogStates.has(this.activeTabId)) {
1029
+ return this.activeTabId;
1030
+ }
1031
+ const tabId = this.pageDialogStates.keys().next().value;
1032
+ if (!tabId) {
1033
+ throw new McpToolError("no_dialog", "No dialog visible.");
1034
+ }
1035
+ return tabId;
1036
+ }
908
1037
  async getActivePageClient() {
909
1038
  const tabs = await this.refreshTabs();
910
1039
  const activeTab = tabs.find((tab) => tab.active);
@@ -999,6 +1128,7 @@ class CdpConnectedBrowserSession {
999
1128
  ...(event.defaultPrompt !== undefined ? { defaultPrompt: event.defaultPrompt } : {}),
1000
1129
  ...(event.url !== undefined ? { url: event.url } : {})
1001
1130
  });
1131
+ this.resolveDialogWaiters(tabId);
1002
1132
  });
1003
1133
  this.installNetworkCollection(tabId, client);
1004
1134
  }
@@ -1046,13 +1176,15 @@ class CdpConnectedBrowserSession {
1046
1176
  client.Network.loadingFinished(async (event) => {
1047
1177
  const request = state.byRequestId.get(event.requestId);
1048
1178
  if (!request) {
1179
+ resolveLoadingDone(state, event.requestId, true);
1049
1180
  return;
1050
1181
  }
1051
1182
  const startedAt = state.startedAt.get(event.requestId);
1052
1183
  if (startedAt !== undefined && event.timestamp !== undefined) {
1053
1184
  request.durationMs = Math.round(event.timestamp * 1000 - startedAt);
1054
1185
  }
1055
- if (canReadResponseBody(request)) {
1186
+ if (canReadResponseBody(request) && !state.bodyRead.has(event.requestId)) {
1187
+ state.bodyRead.add(event.requestId);
1056
1188
  const clientNetwork = client.Network;
1057
1189
  const body = await clientNetwork?.getResponseBody({ requestId: event.requestId }).catch(() => undefined);
1058
1190
  if (body) {
@@ -1061,10 +1193,12 @@ class CdpConnectedBrowserSession {
1061
1193
  : body.body;
1062
1194
  }
1063
1195
  }
1196
+ resolveLoadingDone(state, event.requestId, true);
1064
1197
  });
1065
1198
  client.Network.loadingFailed((event) => {
1066
1199
  const request = state.byRequestId.get(event.requestId);
1067
1200
  if (!request) {
1201
+ resolveLoadingDone(state, event.requestId, false);
1068
1202
  return;
1069
1203
  }
1070
1204
  request.failureText = event.errorText ?? "Unknown error";
@@ -1072,6 +1206,7 @@ class CdpConnectedBrowserSession {
1072
1206
  if (startedAt !== undefined && event.timestamp !== undefined) {
1073
1207
  request.durationMs = Math.round(event.timestamp * 1000 - startedAt);
1074
1208
  }
1209
+ resolveLoadingDone(state, event.requestId, false);
1075
1210
  });
1076
1211
  }
1077
1212
  ensureConsoleState(tabId) {
@@ -1093,7 +1228,10 @@ class CdpConnectedBrowserSession {
1093
1228
  state = {
1094
1229
  requests: [],
1095
1230
  byRequestId: new Map(),
1096
- startedAt: new Map()
1231
+ startedAt: new Map(),
1232
+ hydratedPerformanceResources: false,
1233
+ loadingDone: new Map(),
1234
+ bodyRead: new Set()
1097
1235
  };
1098
1236
  this.pageNetworkStates.set(tabId, state);
1099
1237
  }
@@ -1109,10 +1247,82 @@ class CdpConnectedBrowserSession {
1109
1247
  this.pageNetworkStates.set(tabId, {
1110
1248
  requests: [],
1111
1249
  byRequestId: new Map(),
1112
- startedAt: new Map()
1250
+ startedAt: new Map(),
1251
+ hydratedPerformanceResources: false,
1252
+ loadingDone: new Map(),
1253
+ bodyRead: new Set()
1113
1254
  });
1114
1255
  this.pageDialogStates.delete(tabId);
1115
1256
  }
1257
+ async hydratePerformanceResourceRequests(tabId) {
1258
+ const state = this.ensureNetworkState(tabId);
1259
+ if (state.hydratedPerformanceResources) {
1260
+ return;
1261
+ }
1262
+ state.hydratedPerformanceResources = true;
1263
+ const pageClient = await this.getActivePageClient();
1264
+ const contextId = await this.getActiveUtilityContextId(pageClient);
1265
+ const documentRequest = await evaluateCdp(pageClient, String.raw `() => {
1266
+ const navigation = performance.getEntriesByType("navigation")[0];
1267
+ return {
1268
+ url: String(location.href || ""),
1269
+ duration: navigation ? Math.round(Number(navigation.duration || 0)) : undefined
1270
+ };
1271
+ }`, undefined, contextId).catch(() => undefined);
1272
+ if (documentRequest?.url && !Array.from(state.byRequestId.values()).some((request) => request.url === documentRequest.url)) {
1273
+ const requestId = `performance:navigation:${documentRequest.url}`;
1274
+ const request = {
1275
+ index: state.requests.length + 1,
1276
+ requestId,
1277
+ method: "GET",
1278
+ url: documentRequest.url,
1279
+ resourceType: "document",
1280
+ requestHeaders: {},
1281
+ status: 200,
1282
+ statusText: "OK",
1283
+ ...(documentRequest.duration !== undefined ? { durationMs: documentRequest.duration } : {})
1284
+ };
1285
+ state.requests.push(request);
1286
+ state.byRequestId.set(requestId, request);
1287
+ }
1288
+ const resources = await evaluateCdp(pageClient, String.raw `() => performance.getEntriesByType("resource").map((entry) => ({
1289
+ name: String(entry.name || ""),
1290
+ initiatorType: String(entry.initiatorType || "other"),
1291
+ duration: Math.round(Number(entry.duration || 0)),
1292
+ responseStatus: typeof entry.responseStatus === "number" ? entry.responseStatus : undefined
1293
+ }))`, undefined, contextId).catch(() => []);
1294
+ for (const resource of resources) {
1295
+ if (!resource.name || Array.from(state.byRequestId.values()).some((request) => request.url === resource.name)) {
1296
+ continue;
1297
+ }
1298
+ const status = resource.responseStatus && resource.responseStatus > 0
1299
+ ? resource.responseStatus
1300
+ : await this.probeResourceStatus(pageClient, contextId, resource.name);
1301
+ const requestId = `performance:${resource.name}`;
1302
+ const request = {
1303
+ index: state.requests.length + 1,
1304
+ requestId,
1305
+ method: "GET",
1306
+ url: resource.name,
1307
+ resourceType: normalizeResourceType(resource.initiatorType),
1308
+ requestHeaders: {},
1309
+ ...(status !== undefined ? { status, statusText: statusTextForStatus(status) } : {}),
1310
+ ...(resource.duration !== undefined ? { durationMs: resource.duration } : {})
1311
+ };
1312
+ state.requests.push(request);
1313
+ state.byRequestId.set(requestId, request);
1314
+ }
1315
+ }
1316
+ async probeResourceStatus(pageClient, contextId, url) {
1317
+ return evaluateCdp(pageClient, String.raw `async (url) => {
1318
+ try {
1319
+ const response = await fetch(url, { method: "HEAD", cache: "no-store" });
1320
+ return response.status;
1321
+ } catch {
1322
+ return undefined;
1323
+ }
1324
+ }`, url, contextId).catch(() => undefined);
1325
+ }
1116
1326
  addConsoleMessage(tabId, message) {
1117
1327
  const state = this.ensureConsoleState(tabId);
1118
1328
  if (!shouldIncludeConsoleMessage(message.type)) {
@@ -1181,7 +1391,7 @@ class CdpConnectedBrowserSession {
1181
1391
  if (messages.length === 0) {
1182
1392
  return undefined;
1183
1393
  }
1184
- state.logFile ??= path.join(process.cwd(), ".playwright-mcp", `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
1394
+ state.logFile ??= path.join(this.tempDir, `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
1185
1395
  await mkdir(path.dirname(state.logFile), { recursive: true });
1186
1396
  const fromLine = state.logLine + 1;
1187
1397
  for (const message of messages) {
@@ -1191,9 +1401,8 @@ class CdpConnectedBrowserSession {
1191
1401
  state.logLine += logLine.split("\n").length - 1;
1192
1402
  }
1193
1403
  state.nextMessageIndex = state.messages.length;
1194
- const relativePath = path.relative(process.cwd(), state.logFile);
1195
1404
  const lineRange = fromLine === state.logLine ? `#L${fromLine}` : `#L${fromLine}-L${state.logLine}`;
1196
- return `${relativePath}${lineRange}`;
1405
+ return `${state.logFile}${lineRange}`;
1197
1406
  }
1198
1407
  }
1199
1408
  async function waitForCdpDocumentReady(client, timeoutMs) {
@@ -1282,6 +1491,59 @@ function canReadResponseBody(request) {
1282
1491
  }
1283
1492
  return request.status !== 204 && request.status !== 304 && !(request.status >= 100 && request.status < 200);
1284
1493
  }
1494
+ function loadingDoneEntry(state, requestId) {
1495
+ let entry = state.loadingDone.get(requestId);
1496
+ if (!entry) {
1497
+ let resolve;
1498
+ let reject;
1499
+ const promise = new Promise((res, rej) => {
1500
+ resolve = res;
1501
+ reject = rej;
1502
+ });
1503
+ entry = { promise, resolve, reject };
1504
+ state.loadingDone.set(requestId, entry);
1505
+ }
1506
+ return entry;
1507
+ }
1508
+ function resolveLoadingDone(state, requestId, success) {
1509
+ const entry = state.loadingDone.get(requestId);
1510
+ if (!entry) {
1511
+ return;
1512
+ }
1513
+ state.loadingDone.delete(requestId);
1514
+ if (success) {
1515
+ entry.resolve();
1516
+ }
1517
+ else {
1518
+ entry.reject(new Error("Request failed before the response body was available."));
1519
+ }
1520
+ }
1521
+ async function waitForLoadingDone(state, requestId, timeoutMs) {
1522
+ const entry = loadingDoneEntry(state, requestId);
1523
+ await Promise.race([
1524
+ entry.promise,
1525
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
1526
+ ]);
1527
+ }
1528
+ function statusTextForStatus(status) {
1529
+ if (status === 200)
1530
+ return "OK";
1531
+ if (status === 204)
1532
+ return "No Content";
1533
+ if (status === 304)
1534
+ return "Not Modified";
1535
+ if (status === 400)
1536
+ return "Bad Request";
1537
+ if (status === 401)
1538
+ return "Unauthorized";
1539
+ if (status === 403)
1540
+ return "Forbidden";
1541
+ if (status === 404)
1542
+ return "Not Found";
1543
+ if (status === 500)
1544
+ return "Internal Server Error";
1545
+ return "";
1546
+ }
1285
1547
  function cloneNetworkRequest(request) {
1286
1548
  return {
1287
1549
  ...request,
@@ -1381,12 +1643,17 @@ class BidiConnectedBrowserSession {
1381
1643
  pageConsoleStates = new Map();
1382
1644
  pageNetworkStates = new Map();
1383
1645
  pageDialogStates = new Map();
1646
+ dialogWaiters = new Map();
1384
1647
  bidiListeners = new Map();
1385
1648
  responseDataCollector;
1386
1649
  activeTabId;
1387
1650
  ownsSession = false;
1388
- constructor(client) {
1651
+ tempDir;
1652
+ constructor(client, tempDir) {
1389
1653
  this.client = client;
1654
+ this.tempDir = configuredTempDir({
1655
+ ...(tempDir !== undefined ? { tempDir } : {})
1656
+ });
1390
1657
  }
1391
1658
  static async connect(args) {
1392
1659
  if (args.browser && args.browser !== "firefox") {
@@ -1398,15 +1665,12 @@ class BidiConnectedBrowserSession {
1398
1665
  }
1399
1666
  const client = await getBidiClientFactory()({
1400
1667
  browserName: "firefox",
1401
- webSocketUrl: normalizeFirefoxBidiEndpoint(args.endpoint)
1668
+ webSocketUrl: normalizeFirefoxBidiEndpoint(args.endpoint, args.sessionId)
1402
1669
  });
1403
- const session = new BidiConnectedBrowserSession(client);
1404
- session.ownsSession = await ensureMcpBiDiSession(client, args.endpoint);
1670
+ const session = new BidiConnectedBrowserSession(client, args.tempDir);
1671
+ session.ownsSession = await ensureMcpBiDiSession(client, args.endpoint, args.sessionId);
1405
1672
  await session.initialize();
1406
- const tabs = await session.refreshTabs();
1407
- if (tabs.length === 0) {
1408
- await session.newTab();
1409
- }
1673
+ await session.refreshTabs();
1410
1674
  return session;
1411
1675
  }
1412
1676
  async version() {
@@ -1465,10 +1729,14 @@ class BidiConnectedBrowserSession {
1465
1729
  async snapshot(request = {}) {
1466
1730
  const tabId = await this.getActiveTabId();
1467
1731
  const result = await retryUntilReady(() => evaluateBiDi(this.client, tabId, ARIA_SNAPSHOT_EVALUATE_SOURCE, toAriaSnapshotPayload(request)));
1468
- return toBrowserSnapshot(result, request, {
1732
+ const snapshot = toBrowserSnapshot(result, request, {
1469
1733
  console: this.consoleSummary(tabId),
1470
1734
  consoleLink: await this.takeConsoleLink(tabId)
1471
1735
  });
1736
+ return {
1737
+ ...snapshot,
1738
+ retryable: true
1739
+ };
1472
1740
  }
1473
1741
  async consoleMessages(level = "info", all = false) {
1474
1742
  const activeTabId = await this.getActiveTabId();
@@ -1558,10 +1826,28 @@ class BidiConnectedBrowserSession {
1558
1826
  parameters: { pointerType: "mouse" },
1559
1827
  actions: pointerActions
1560
1828
  });
1561
- await this.client.inputPerformActions({
1829
+ // TODO(bidi): A synchronous alert()/confirm()/prompt() opened by the click
1830
+ // blocks the page's main thread, and in Firefox that also wedges the BiDi
1831
+ // transport: inputPerformActions never resolves while the modal is open.
1832
+ // Unlike CDP (where only the mouse-release call blocks), BiDi dispatches
1833
+ // the whole pointer sequence as one atomic command, so we cannot split it.
1834
+ // Mitigation (NOT a full fix): race the action against the dialog waiter so
1835
+ // a dialog-opening click resolves instead of hanging forever. The residual
1836
+ // performPromise is intentionally left dangling; it resolves later once the
1837
+ // dialog is dismissed.
1838
+ //
1839
+ // KNOWN-UNRESOLVED: even with this race,后续的 BiDi 命令在模态框打开期间仍可能
1840
+ // 整体卡死(见 handleDialog 的 TODO)。Firefox/geckodriver 在 alert 模态下会
1841
+ // 阻塞几乎所有 BiDi 命令,这是浏览器/驱动层的限制,本适配器无法绕过。
1842
+ const performPromise = this.client.inputPerformActions({
1562
1843
  context: tabId,
1563
1844
  actions
1564
1845
  });
1846
+ await Promise.race([
1847
+ performPromise,
1848
+ this.waitForDialog(tabId, options.clickHoldMs + 5000)
1849
+ ]);
1850
+ performPromise.catch(() => { });
1565
1851
  await this.client.inputReleaseActions({ context: tabId }).catch(() => { });
1566
1852
  }
1567
1853
  async drag(start, end, options) {
@@ -1812,16 +2098,27 @@ class BidiConnectedBrowserSession {
1812
2098
  }
1813
2099
  }
1814
2100
  async handleDialog(accept, promptText) {
1815
- const tabId = await this.getActiveTabId();
2101
+ const tabId = this.dialogTabId();
1816
2102
  if (!this.pageDialogStates.has(tabId)) {
1817
2103
  throw new McpToolError("no_dialog", "No dialog visible.");
1818
2104
  }
1819
2105
  this.pageDialogStates.delete(tabId);
1820
- await this.client.browsingContextHandleUserPrompt({
2106
+ // TODO(bidi): Firefox's browsingContext.handleUserPrompt can stall while a
2107
+ // modal is open — 实测在 alert/confirm 模态下 geckodriver 对该命令的响应会
2108
+ // 长时间不返回(实测 60s+ 不返回,最终靠 MCP 客户端超时才解脱)。这里用
2109
+ // withBiDiTimeout 兜底:最多等 5s 后强制 reject,避免工具调用无限挂起。
2110
+ // 这只是“快速失败”的缓解,并未真正解决“模态框打开时几乎所有 BiDi 命令都
2111
+ // 被卡死”的底层问题。CDP 下这一路径是可靠的,BiDi 暂只能参考 CDP 思路。
2112
+ // KNOWN-UNRESOLVED: 若在模态框打开期间调用本命令,前序的 refreshTabs
2113
+ // (browsingContextGetTree) 等也可能先一步卡死,导致整个 tool 调用超时。
2114
+ await withBiDiTimeout(this.client.browsingContextHandleUserPrompt({
1821
2115
  context: tabId,
1822
2116
  accept,
1823
2117
  ...(promptText !== undefined ? { userText: promptText } : {})
1824
- });
2118
+ }), 5_000);
2119
+ }
2120
+ async hasDialog() {
2121
+ return this.pageDialogStates.size > 0;
1825
2122
  }
1826
2123
  async networkRequests() {
1827
2124
  const tabId = await this.getActiveTabId();
@@ -1832,6 +2129,21 @@ class BidiConnectedBrowserSession {
1832
2129
  const request = this.ensureNetworkState(tabId).requests[index - 1];
1833
2130
  return request ? cloneNetworkRequest(request) : undefined;
1834
2131
  }
2132
+ async fetchResponseBody(index) {
2133
+ const tabId = await this.getActiveTabId();
2134
+ const request = this.ensureNetworkState(tabId).requests[index - 1];
2135
+ if (!request || !request.requestId) {
2136
+ return request?.responseBody;
2137
+ }
2138
+ if (request.responseBody !== undefined) {
2139
+ return request.responseBody;
2140
+ }
2141
+ const body = await this.getResponseBody(request.requestId).catch(() => undefined);
2142
+ if (body !== undefined) {
2143
+ request.responseBody = body;
2144
+ }
2145
+ return request.responseBody;
2146
+ }
1835
2147
  async runCodeUnsafe(code) {
1836
2148
  return this.evaluate(`async () => {
1837
2149
  const fn = eval(${JSON.stringify(`(${code})`)});
@@ -1847,6 +2159,15 @@ class BidiConnectedBrowserSession {
1847
2159
  }`);
1848
2160
  }
1849
2161
  async initialize() {
2162
+ // 顺序很关键:必须先 attachBiDiListeners(),再 sessionSubscribe()。
2163
+ // 实测 Firefox:sessionSubscribe 返回后事件会立即开始涌入;若此刻监听器
2164
+ // 还没注册,最早的一批 network.beforeRequestSent(页面导航/资源请求)会落进
2165
+ // “no registered listener” 分支被静默丢弃,导致 network 列表里缺首页请求。
2166
+ // 调试时观察到 [DEBUG bidi client no-listener] network.beforeRequestSent 连续
2167
+ // 出现几十次,正是此问题。先注册监听器即可避免丢事件。
2168
+ // TODO(bidi): 即便修了顺序,BiDi 网络捕获仍有状态时序问题,见
2169
+ // handleResponseCompleted / networkRequests 的 TODO。
2170
+ this.attachBiDiListeners();
1850
2171
  await this.client.sessionSubscribe({
1851
2172
  events: [
1852
2173
  "browsingContext.userPromptOpened",
@@ -1862,7 +2183,6 @@ class BidiConnectedBrowserSession {
1862
2183
  maxEncodedDataSize: 10_000_000
1863
2184
  }).catch(() => undefined);
1864
2185
  this.responseDataCollector = collectorResult?.collector;
1865
- this.attachBiDiListeners();
1866
2186
  }
1867
2187
  attachBiDiListeners() {
1868
2188
  this.attachBiDiListener("log.entryAdded", (payload) => this.handleLogEntry(payload));
@@ -1904,6 +2224,63 @@ class BidiConnectedBrowserSession {
1904
2224
  targetArg(target) {
1905
2225
  return "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
1906
2226
  }
2227
+ dialogTabId() {
2228
+ if (this.activeTabId && this.pageDialogStates.has(this.activeTabId)) {
2229
+ return this.activeTabId;
2230
+ }
2231
+ const tabId = this.pageDialogStates.keys().next().value;
2232
+ if (!tabId) {
2233
+ throw new McpToolError("no_dialog", "No dialog visible.");
2234
+ }
2235
+ return tabId;
2236
+ }
2237
+ waitForDialog(tabId, timeoutMs) {
2238
+ if (this.pageDialogStates.has(tabId)) {
2239
+ return Promise.resolve();
2240
+ }
2241
+ return new Promise((resolve, reject) => {
2242
+ const waiter = {
2243
+ resolve: () => {
2244
+ if (waiter.timer) {
2245
+ clearTimeout(waiter.timer);
2246
+ }
2247
+ this.removeDialogWaiter(tabId, waiter);
2248
+ resolve();
2249
+ },
2250
+ reject: (error) => {
2251
+ if (waiter.timer) {
2252
+ clearTimeout(waiter.timer);
2253
+ }
2254
+ this.removeDialogWaiter(tabId, waiter);
2255
+ reject(error);
2256
+ }
2257
+ };
2258
+ waiter.timer = setTimeout(() => waiter.reject?.(new Error("Timed out waiting for dialog.")), timeoutMs);
2259
+ const waiters = this.dialogWaiters.get(tabId) ?? new Set();
2260
+ waiters.add(waiter);
2261
+ this.dialogWaiters.set(tabId, waiters);
2262
+ });
2263
+ }
2264
+ resolveDialogWaiters(tabId) {
2265
+ const waiters = this.dialogWaiters.get(tabId);
2266
+ if (!waiters) {
2267
+ return;
2268
+ }
2269
+ this.dialogWaiters.delete(tabId);
2270
+ for (const waiter of waiters) {
2271
+ waiter.resolve();
2272
+ }
2273
+ }
2274
+ removeDialogWaiter(tabId, waiter) {
2275
+ const waiters = this.dialogWaiters.get(tabId);
2276
+ if (!waiters) {
2277
+ return;
2278
+ }
2279
+ waiters.delete(waiter);
2280
+ if (waiters.size === 0) {
2281
+ this.dialogWaiters.delete(tabId);
2282
+ }
2283
+ }
1907
2284
  async actionPoint(tabId, target) {
1908
2285
  const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
1909
2286
  const point = await evaluateBiDi(this.client, tabId, source, this.targetArg(target));
@@ -1964,6 +2341,11 @@ class BidiConnectedBrowserSession {
1964
2341
  type: event.type ?? "alert",
1965
2342
  ...(event.defaultValue !== undefined ? { defaultPrompt: event.defaultValue } : {})
1966
2343
  });
2344
+ // 这里必须 resolve 对话框等待器:BiDi 的 click 会 race inputPerformActions
2345
+ // 与 waitForDialog(见 click 注释)。若不在此 resolve,alert() 触发后
2346
+ // waitForDialog 会一直 pending,click 永久挂起。CDP 侧的
2347
+ // javascriptDialogOpening 处理也调用了 resolveDialogWaiters,两侧需对齐。
2348
+ this.resolveDialogWaiters(event.context);
1967
2349
  }
1968
2350
  handleBeforeRequestSent(payload) {
1969
2351
  const event = parseBidiNetworkEvent(payload);
@@ -1984,6 +2366,12 @@ class BidiConnectedBrowserSession {
1984
2366
  request.url = event.request.url;
1985
2367
  request.resourceType = normalizeResourceType(event.request.destination);
1986
2368
  request.requestHeaders = normalizeHeaders(bidiHeadersToRecord(event.request.headers));
2369
+ // TODO(bidi): BiDi 的 network.beforeRequestSent 只给 bodySize,不内联 POST
2370
+ // body。这里只置了个空串占位(requestBody ??= ""),真实 POST 体从未填充,
2371
+ // 因此 browser_network_request 的 part="request-body" 在 BiDi 下只能拿到空串。
2372
+ // CDP 侧通过 Network.requestWillBeSent 的 request.postData 直接拿到 body。
2373
+ // BiDi 若要拿到 body 需另发 network.getRequestPostData 请求(Firefox 支持不稳),
2374
+ // 暂未实现 —— 这是已知缺口,等 geckodriver 稳定后再补。
1987
2375
  if (event.request.bodySize !== undefined && event.request.bodySize > 0) {
1988
2376
  request.requestBody ??= "";
1989
2377
  }
@@ -2020,6 +2408,17 @@ class BidiConnectedBrowserSession {
2020
2408
  if (startedAt !== undefined && event.timestamp !== undefined) {
2021
2409
  request.durationMs = Math.round(event.timestamp - startedAt);
2022
2410
  }
2411
+ // TODO(bidi): BiDi 网络事件是乱序/延迟到达的,且 status 与 body 的可用时机
2412
+ // 不可靠。实测 Firefox:
2413
+ // - beforeRequestSent 与 responseCompleted 之间存在竞态,waitForNetworkRequest
2414
+ // 在 body/status 尚未就绪时就可能匹配到请求并返回;
2415
+ // - 随后再次 browser_network_requests 时,同一个 POST /api 请求有时会从列表
2416
+ // 里“消失”(疑似 responseCompleted 到达途中 ensureNetworkRequest 重建了条目
2417
+ // 或上下文切换所致,未完全定位)。
2418
+ // 这导致 BiDi 下无法像 CDP 那样做强一致的网络契约断言(=> [status] OK 全匹配)。
2419
+ // 这里仅在 responseCompleted 时尽力补 body;status 缺失的窗口由调用方容忍。
2420
+ // KNOWN-UNRESOLVED: BiDi 网络捕获的一致性问题,需等 Firefox/geckodriver 事件
2421
+ // 时序稳定后再追,或改用 network.getDataCollector 统一采集。
2023
2422
  if (canReadResponseBody(request)) {
2024
2423
  const body = await this.getResponseBody(event.request.request).catch(() => undefined);
2025
2424
  if (body !== undefined) {
@@ -2086,7 +2485,10 @@ class BidiConnectedBrowserSession {
2086
2485
  state = {
2087
2486
  requests: [],
2088
2487
  byRequestId: new Map(),
2089
- startedAt: new Map()
2488
+ startedAt: new Map(),
2489
+ hydratedPerformanceResources: false,
2490
+ loadingDone: new Map(),
2491
+ bodyRead: new Set()
2090
2492
  };
2091
2493
  this.pageNetworkStates.set(tabId, state);
2092
2494
  }
@@ -2102,7 +2504,10 @@ class BidiConnectedBrowserSession {
2102
2504
  this.pageNetworkStates.set(tabId, {
2103
2505
  requests: [],
2104
2506
  byRequestId: new Map(),
2105
- startedAt: new Map()
2507
+ startedAt: new Map(),
2508
+ hydratedPerformanceResources: false,
2509
+ loadingDone: new Map(),
2510
+ bodyRead: new Set()
2106
2511
  });
2107
2512
  this.pageDialogStates.delete(tabId);
2108
2513
  }
@@ -2133,7 +2538,7 @@ class BidiConnectedBrowserSession {
2133
2538
  if (messages.length === 0) {
2134
2539
  return undefined;
2135
2540
  }
2136
- state.logFile ??= path.join(process.cwd(), ".playwright-mcp", `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
2541
+ state.logFile ??= path.join(this.tempDir, `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
2137
2542
  await mkdir(path.dirname(state.logFile), { recursive: true });
2138
2543
  const fromLine = state.logLine + 1;
2139
2544
  for (const message of messages) {
@@ -2143,21 +2548,24 @@ class BidiConnectedBrowserSession {
2143
2548
  state.logLine += logLine.split("\n").length - 1;
2144
2549
  }
2145
2550
  state.nextMessageIndex = state.messages.length;
2146
- const relativePath = path.relative(process.cwd(), state.logFile);
2147
2551
  const lineRange = fromLine === state.logLine ? `#L${fromLine}` : `#L${fromLine}-L${state.logLine}`;
2148
- return `${relativePath}${lineRange}`;
2552
+ return `${state.logFile}${lineRange}`;
2149
2553
  }
2150
2554
  }
2151
- function normalizeFirefoxBidiEndpoint(endpoint) {
2555
+ function normalizeFirefoxBidiEndpoint(endpoint, sessionId) {
2152
2556
  const url = new URL(endpoint);
2557
+ if (sessionId) {
2558
+ url.pathname = `/session/${sessionId}`;
2559
+ return url.toString();
2560
+ }
2153
2561
  if (url.pathname === "/" || url.pathname === "") {
2154
2562
  url.pathname = "/session";
2155
2563
  }
2156
2564
  return url.toString();
2157
2565
  }
2158
- async function ensureMcpBiDiSession(client, endpoint) {
2566
+ async function ensureMcpBiDiSession(client, endpoint, sessionId) {
2159
2567
  await client.sessionStatus({});
2160
- if (isSessionSpecificFirefoxBidiEndpoint(endpoint)) {
2568
+ if (sessionId || isSessionSpecificFirefoxBidiEndpoint(endpoint)) {
2161
2569
  return false;
2162
2570
  }
2163
2571
  try {