@roxybrowser/playwright 2.0.2-beta.2 → 2.0.2-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,21 @@ import { ACTION_POINT_EVALUATE_SOURCE, ACTION_POINT_BY_SELECTOR_SOURCE } from ".
10
10
  function delay(ms) {
11
11
  return new Promise((resolve) => setTimeout(resolve, ms));
12
12
  }
13
+ async function withBiDiTimeout(promise, timeoutMs) {
14
+ let timer;
15
+ try {
16
+ return await Promise.race([
17
+ promise,
18
+ new Promise((_, reject) => {
19
+ timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms.`)), timeoutMs);
20
+ })
21
+ ]);
22
+ }
23
+ finally {
24
+ if (timer)
25
+ clearTimeout(timer);
26
+ }
27
+ }
13
28
  const chromeRemoteInterface = ("default" in cdpModule
14
29
  ? cdpModule.default
15
30
  : cdpModule);
@@ -449,6 +464,7 @@ class CdpConnectedBrowserSession {
449
464
  pageConsoleStates = new Map();
450
465
  pageNetworkStates = new Map();
451
466
  pageDialogStates = new Map();
467
+ dialogWaiters = new Map();
452
468
  activeTabId;
453
469
  versionString = "Chromium/unknown";
454
470
  constructor(browserClient, connection) {
@@ -469,10 +485,7 @@ class CdpConnectedBrowserSession {
469
485
  });
470
486
  const session = new CdpConnectedBrowserSession(browserClient, connection);
471
487
  session.versionString = version.Browser;
472
- const tabs = await session.refreshTabs();
473
- if (tabs.length === 0) {
474
- await session.newTab();
475
- }
488
+ await session.refreshTabs();
476
489
  await session.getActivePageClient().catch(() => undefined);
477
490
  return session;
478
491
  }
@@ -536,6 +549,7 @@ class CdpConnectedBrowserSession {
536
549
  async click(target, options) {
537
550
  const pageClient = await this.getActivePageClient();
538
551
  const contextId = await this.getActiveUtilityContextId(pageClient);
552
+ const tabId = await this.getActiveTabId();
539
553
  const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
540
554
  const arg = "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
541
555
  const point = await evaluateCdp(pageClient, source, arg, contextId);
@@ -569,7 +583,7 @@ class CdpConnectedBrowserSession {
569
583
  modifiers: modifiersMask
570
584
  });
571
585
  await delay(options.clickHoldMs);
572
- await pageClient.Input.dispatchMouseEvent({
586
+ const releasePromise = pageClient.Input.dispatchMouseEvent({
573
587
  type: "mouseReleased",
574
588
  x: point.x,
575
589
  y: point.y,
@@ -577,6 +591,10 @@ class CdpConnectedBrowserSession {
577
591
  clickCount,
578
592
  modifiers: modifiersMask
579
593
  });
594
+ await Promise.race([
595
+ releasePromise,
596
+ this.waitForDialog(tabId, options.clickHoldMs + 1000)
597
+ ]);
580
598
  }
581
599
  }
582
600
  async drag(start, end, options) {
@@ -833,26 +851,105 @@ class CdpConnectedBrowserSession {
833
851
  }
834
852
  }
835
853
  async handleDialog(accept, promptText) {
836
- const tabId = await this.getActiveTabId();
854
+ const tabId = this.dialogTabId();
837
855
  if (!this.pageDialogStates.has(tabId)) {
838
856
  throw new McpToolError("no_dialog", "No dialog visible.");
839
857
  }
840
- const pageClient = await this.getActivePageClient();
858
+ const pageClient = this.pageClients.get(tabId) ?? await this.getActivePageClient();
841
859
  this.pageDialogStates.delete(tabId);
842
860
  await pageClient.Page.handleJavaScriptDialog({
843
861
  accept,
844
862
  ...(promptText !== undefined ? { promptText } : {})
845
863
  });
846
864
  }
865
+ async hasDialog() {
866
+ return this.pageDialogStates.size > 0;
867
+ }
868
+ waitForDialog(tabId, timeoutMs) {
869
+ if (this.pageDialogStates.has(tabId)) {
870
+ return Promise.resolve();
871
+ }
872
+ return new Promise((resolve) => {
873
+ const waiter = {
874
+ resolve: () => {
875
+ if (waiter.timer) {
876
+ clearTimeout(waiter.timer);
877
+ }
878
+ this.removeDialogWaiter(tabId, waiter);
879
+ resolve();
880
+ }
881
+ };
882
+ waiter.timer = setTimeout(() => waiter.resolve(), timeoutMs);
883
+ const waiters = this.dialogWaiters.get(tabId) ?? new Set();
884
+ waiters.add(waiter);
885
+ this.dialogWaiters.set(tabId, waiters);
886
+ });
887
+ }
888
+ resolveDialogWaiters(tabId) {
889
+ const waiters = this.dialogWaiters.get(tabId);
890
+ if (!waiters) {
891
+ return;
892
+ }
893
+ this.dialogWaiters.delete(tabId);
894
+ for (const waiter of waiters) {
895
+ waiter.resolve();
896
+ }
897
+ }
898
+ removeDialogWaiter(tabId, waiter) {
899
+ const waiters = this.dialogWaiters.get(tabId);
900
+ if (!waiters) {
901
+ return;
902
+ }
903
+ waiters.delete(waiter);
904
+ if (waiters.size === 0) {
905
+ this.dialogWaiters.delete(tabId);
906
+ }
907
+ }
847
908
  async networkRequests() {
848
909
  const tabId = await this.getActiveTabId();
910
+ await this.hydratePerformanceResourceRequests(tabId);
849
911
  return this.ensureNetworkState(tabId).requests.map(cloneNetworkRequest);
850
912
  }
851
913
  async networkRequest(index) {
852
914
  const tabId = await this.getActiveTabId();
915
+ await this.hydratePerformanceResourceRequests(tabId);
853
916
  const request = this.ensureNetworkState(tabId).requests[index - 1];
854
917
  return request ? cloneNetworkRequest(request) : undefined;
855
918
  }
919
+ async fetchResponseBody(index) {
920
+ const tabId = await this.getActiveTabId();
921
+ const state = this.ensureNetworkState(tabId);
922
+ const request = state.requests[index - 1];
923
+ if (!request || !request.requestId) {
924
+ return request?.responseBody;
925
+ }
926
+ if (!canReadResponseBody(request)) {
927
+ return undefined;
928
+ }
929
+ if (request.responseBody !== undefined) {
930
+ return request.responseBody;
931
+ }
932
+ await waitForLoadingDone(state, request.requestId, 5_000).catch(() => undefined);
933
+ if (request.responseBody !== undefined) {
934
+ return request.responseBody;
935
+ }
936
+ if (state.bodyRead.has(request.requestId)) {
937
+ return undefined;
938
+ }
939
+ state.bodyRead.add(request.requestId);
940
+ const pageClient = this.pageClients.get(tabId) ?? await this.getActivePageClient();
941
+ const clientNetwork = pageClient.Network;
942
+ if (!clientNetwork) {
943
+ return undefined;
944
+ }
945
+ const body = await clientNetwork.getResponseBody({ requestId: request.requestId }).catch(() => undefined);
946
+ if (body) {
947
+ request.responseBody = body.base64Encoded
948
+ ? Buffer.from(body.body, "base64").toString("utf8")
949
+ : body.body;
950
+ }
951
+ return request.responseBody;
952
+ }
856
953
  async runCodeUnsafe(code) {
857
954
  const pageClient = await this.getActivePageClient();
858
955
  const contextId = await this.getActiveUtilityContextId(pageClient);
@@ -922,6 +1019,16 @@ class CdpConnectedBrowserSession {
922
1019
  }
923
1020
  return activeTab.id;
924
1021
  }
1022
+ dialogTabId() {
1023
+ if (this.activeTabId && this.pageDialogStates.has(this.activeTabId)) {
1024
+ return this.activeTabId;
1025
+ }
1026
+ const tabId = this.pageDialogStates.keys().next().value;
1027
+ if (!tabId) {
1028
+ throw new McpToolError("no_dialog", "No dialog visible.");
1029
+ }
1030
+ return tabId;
1031
+ }
925
1032
  async getActivePageClient() {
926
1033
  const tabs = await this.refreshTabs();
927
1034
  const activeTab = tabs.find((tab) => tab.active);
@@ -1016,6 +1123,7 @@ class CdpConnectedBrowserSession {
1016
1123
  ...(event.defaultPrompt !== undefined ? { defaultPrompt: event.defaultPrompt } : {}),
1017
1124
  ...(event.url !== undefined ? { url: event.url } : {})
1018
1125
  });
1126
+ this.resolveDialogWaiters(tabId);
1019
1127
  });
1020
1128
  this.installNetworkCollection(tabId, client);
1021
1129
  }
@@ -1063,13 +1171,15 @@ class CdpConnectedBrowserSession {
1063
1171
  client.Network.loadingFinished(async (event) => {
1064
1172
  const request = state.byRequestId.get(event.requestId);
1065
1173
  if (!request) {
1174
+ resolveLoadingDone(state, event.requestId, true);
1066
1175
  return;
1067
1176
  }
1068
1177
  const startedAt = state.startedAt.get(event.requestId);
1069
1178
  if (startedAt !== undefined && event.timestamp !== undefined) {
1070
1179
  request.durationMs = Math.round(event.timestamp * 1000 - startedAt);
1071
1180
  }
1072
- if (canReadResponseBody(request)) {
1181
+ if (canReadResponseBody(request) && !state.bodyRead.has(event.requestId)) {
1182
+ state.bodyRead.add(event.requestId);
1073
1183
  const clientNetwork = client.Network;
1074
1184
  const body = await clientNetwork?.getResponseBody({ requestId: event.requestId }).catch(() => undefined);
1075
1185
  if (body) {
@@ -1078,10 +1188,12 @@ class CdpConnectedBrowserSession {
1078
1188
  : body.body;
1079
1189
  }
1080
1190
  }
1191
+ resolveLoadingDone(state, event.requestId, true);
1081
1192
  });
1082
1193
  client.Network.loadingFailed((event) => {
1083
1194
  const request = state.byRequestId.get(event.requestId);
1084
1195
  if (!request) {
1196
+ resolveLoadingDone(state, event.requestId, false);
1085
1197
  return;
1086
1198
  }
1087
1199
  request.failureText = event.errorText ?? "Unknown error";
@@ -1089,6 +1201,7 @@ class CdpConnectedBrowserSession {
1089
1201
  if (startedAt !== undefined && event.timestamp !== undefined) {
1090
1202
  request.durationMs = Math.round(event.timestamp * 1000 - startedAt);
1091
1203
  }
1204
+ resolveLoadingDone(state, event.requestId, false);
1092
1205
  });
1093
1206
  }
1094
1207
  ensureConsoleState(tabId) {
@@ -1110,7 +1223,10 @@ class CdpConnectedBrowserSession {
1110
1223
  state = {
1111
1224
  requests: [],
1112
1225
  byRequestId: new Map(),
1113
- startedAt: new Map()
1226
+ startedAt: new Map(),
1227
+ hydratedPerformanceResources: false,
1228
+ loadingDone: new Map(),
1229
+ bodyRead: new Set()
1114
1230
  };
1115
1231
  this.pageNetworkStates.set(tabId, state);
1116
1232
  }
@@ -1126,10 +1242,82 @@ class CdpConnectedBrowserSession {
1126
1242
  this.pageNetworkStates.set(tabId, {
1127
1243
  requests: [],
1128
1244
  byRequestId: new Map(),
1129
- startedAt: new Map()
1245
+ startedAt: new Map(),
1246
+ hydratedPerformanceResources: false,
1247
+ loadingDone: new Map(),
1248
+ bodyRead: new Set()
1130
1249
  });
1131
1250
  this.pageDialogStates.delete(tabId);
1132
1251
  }
1252
+ async hydratePerformanceResourceRequests(tabId) {
1253
+ const state = this.ensureNetworkState(tabId);
1254
+ if (state.hydratedPerformanceResources) {
1255
+ return;
1256
+ }
1257
+ state.hydratedPerformanceResources = true;
1258
+ const pageClient = await this.getActivePageClient();
1259
+ const contextId = await this.getActiveUtilityContextId(pageClient);
1260
+ const documentRequest = await evaluateCdp(pageClient, String.raw `() => {
1261
+ const navigation = performance.getEntriesByType("navigation")[0];
1262
+ return {
1263
+ url: String(location.href || ""),
1264
+ duration: navigation ? Math.round(Number(navigation.duration || 0)) : undefined
1265
+ };
1266
+ }`, undefined, contextId).catch(() => undefined);
1267
+ if (documentRequest?.url && !Array.from(state.byRequestId.values()).some((request) => request.url === documentRequest.url)) {
1268
+ const requestId = `performance:navigation:${documentRequest.url}`;
1269
+ const request = {
1270
+ index: state.requests.length + 1,
1271
+ requestId,
1272
+ method: "GET",
1273
+ url: documentRequest.url,
1274
+ resourceType: "document",
1275
+ requestHeaders: {},
1276
+ status: 200,
1277
+ statusText: "OK",
1278
+ ...(documentRequest.duration !== undefined ? { durationMs: documentRequest.duration } : {})
1279
+ };
1280
+ state.requests.push(request);
1281
+ state.byRequestId.set(requestId, request);
1282
+ }
1283
+ const resources = await evaluateCdp(pageClient, String.raw `() => performance.getEntriesByType("resource").map((entry) => ({
1284
+ name: String(entry.name || ""),
1285
+ initiatorType: String(entry.initiatorType || "other"),
1286
+ duration: Math.round(Number(entry.duration || 0)),
1287
+ responseStatus: typeof entry.responseStatus === "number" ? entry.responseStatus : undefined
1288
+ }))`, undefined, contextId).catch(() => []);
1289
+ for (const resource of resources) {
1290
+ if (!resource.name || Array.from(state.byRequestId.values()).some((request) => request.url === resource.name)) {
1291
+ continue;
1292
+ }
1293
+ const status = resource.responseStatus && resource.responseStatus > 0
1294
+ ? resource.responseStatus
1295
+ : await this.probeResourceStatus(pageClient, contextId, resource.name);
1296
+ const requestId = `performance:${resource.name}`;
1297
+ const request = {
1298
+ index: state.requests.length + 1,
1299
+ requestId,
1300
+ method: "GET",
1301
+ url: resource.name,
1302
+ resourceType: normalizeResourceType(resource.initiatorType),
1303
+ requestHeaders: {},
1304
+ ...(status !== undefined ? { status, statusText: statusTextForStatus(status) } : {}),
1305
+ ...(resource.duration !== undefined ? { durationMs: resource.duration } : {})
1306
+ };
1307
+ state.requests.push(request);
1308
+ state.byRequestId.set(requestId, request);
1309
+ }
1310
+ }
1311
+ async probeResourceStatus(pageClient, contextId, url) {
1312
+ return evaluateCdp(pageClient, String.raw `async (url) => {
1313
+ try {
1314
+ const response = await fetch(url, { method: "HEAD", cache: "no-store" });
1315
+ return response.status;
1316
+ } catch {
1317
+ return undefined;
1318
+ }
1319
+ }`, url, contextId).catch(() => undefined);
1320
+ }
1133
1321
  addConsoleMessage(tabId, message) {
1134
1322
  const state = this.ensureConsoleState(tabId);
1135
1323
  if (!shouldIncludeConsoleMessage(message.type)) {
@@ -1299,6 +1487,59 @@ function canReadResponseBody(request) {
1299
1487
  }
1300
1488
  return request.status !== 204 && request.status !== 304 && !(request.status >= 100 && request.status < 200);
1301
1489
  }
1490
+ function loadingDoneEntry(state, requestId) {
1491
+ let entry = state.loadingDone.get(requestId);
1492
+ if (!entry) {
1493
+ let resolve;
1494
+ let reject;
1495
+ const promise = new Promise((res, rej) => {
1496
+ resolve = res;
1497
+ reject = rej;
1498
+ });
1499
+ entry = { promise, resolve, reject };
1500
+ state.loadingDone.set(requestId, entry);
1501
+ }
1502
+ return entry;
1503
+ }
1504
+ function resolveLoadingDone(state, requestId, success) {
1505
+ const entry = state.loadingDone.get(requestId);
1506
+ if (!entry) {
1507
+ return;
1508
+ }
1509
+ state.loadingDone.delete(requestId);
1510
+ if (success) {
1511
+ entry.resolve();
1512
+ }
1513
+ else {
1514
+ entry.reject(new Error("Request failed before the response body was available."));
1515
+ }
1516
+ }
1517
+ async function waitForLoadingDone(state, requestId, timeoutMs) {
1518
+ const entry = loadingDoneEntry(state, requestId);
1519
+ await Promise.race([
1520
+ entry.promise,
1521
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
1522
+ ]);
1523
+ }
1524
+ function statusTextForStatus(status) {
1525
+ if (status === 200)
1526
+ return "OK";
1527
+ if (status === 204)
1528
+ return "No Content";
1529
+ if (status === 304)
1530
+ return "Not Modified";
1531
+ if (status === 400)
1532
+ return "Bad Request";
1533
+ if (status === 401)
1534
+ return "Unauthorized";
1535
+ if (status === 403)
1536
+ return "Forbidden";
1537
+ if (status === 404)
1538
+ return "Not Found";
1539
+ if (status === 500)
1540
+ return "Internal Server Error";
1541
+ return "";
1542
+ }
1302
1543
  function cloneNetworkRequest(request) {
1303
1544
  return {
1304
1545
  ...request,
@@ -1398,6 +1639,7 @@ class BidiConnectedBrowserSession {
1398
1639
  pageConsoleStates = new Map();
1399
1640
  pageNetworkStates = new Map();
1400
1641
  pageDialogStates = new Map();
1642
+ dialogWaiters = new Map();
1401
1643
  bidiListeners = new Map();
1402
1644
  responseDataCollector;
1403
1645
  activeTabId;
@@ -1420,10 +1662,7 @@ class BidiConnectedBrowserSession {
1420
1662
  const session = new BidiConnectedBrowserSession(client);
1421
1663
  session.ownsSession = await ensureMcpBiDiSession(client, args.endpoint, args.sessionId);
1422
1664
  await session.initialize();
1423
- const tabs = await session.refreshTabs();
1424
- if (tabs.length === 0) {
1425
- await session.newTab();
1426
- }
1665
+ await session.refreshTabs();
1427
1666
  return session;
1428
1667
  }
1429
1668
  async version() {
@@ -1482,10 +1721,14 @@ class BidiConnectedBrowserSession {
1482
1721
  async snapshot(request = {}) {
1483
1722
  const tabId = await this.getActiveTabId();
1484
1723
  const result = await retryUntilReady(() => evaluateBiDi(this.client, tabId, ARIA_SNAPSHOT_EVALUATE_SOURCE, toAriaSnapshotPayload(request)));
1485
- return toBrowserSnapshot(result, request, {
1724
+ const snapshot = toBrowserSnapshot(result, request, {
1486
1725
  console: this.consoleSummary(tabId),
1487
1726
  consoleLink: await this.takeConsoleLink(tabId)
1488
1727
  });
1728
+ return {
1729
+ ...snapshot,
1730
+ retryable: true
1731
+ };
1489
1732
  }
1490
1733
  async consoleMessages(level = "info", all = false) {
1491
1734
  const activeTabId = await this.getActiveTabId();
@@ -1575,10 +1818,28 @@ class BidiConnectedBrowserSession {
1575
1818
  parameters: { pointerType: "mouse" },
1576
1819
  actions: pointerActions
1577
1820
  });
1578
- await this.client.inputPerformActions({
1821
+ // TODO(bidi): A synchronous alert()/confirm()/prompt() opened by the click
1822
+ // blocks the page's main thread, and in Firefox that also wedges the BiDi
1823
+ // transport: inputPerformActions never resolves while the modal is open.
1824
+ // Unlike CDP (where only the mouse-release call blocks), BiDi dispatches
1825
+ // the whole pointer sequence as one atomic command, so we cannot split it.
1826
+ // Mitigation (NOT a full fix): race the action against the dialog waiter so
1827
+ // a dialog-opening click resolves instead of hanging forever. The residual
1828
+ // performPromise is intentionally left dangling; it resolves later once the
1829
+ // dialog is dismissed.
1830
+ //
1831
+ // KNOWN-UNRESOLVED: even with this race,后续的 BiDi 命令在模态框打开期间仍可能
1832
+ // 整体卡死(见 handleDialog 的 TODO)。Firefox/geckodriver 在 alert 模态下会
1833
+ // 阻塞几乎所有 BiDi 命令,这是浏览器/驱动层的限制,本适配器无法绕过。
1834
+ const performPromise = this.client.inputPerformActions({
1579
1835
  context: tabId,
1580
1836
  actions
1581
1837
  });
1838
+ await Promise.race([
1839
+ performPromise,
1840
+ this.waitForDialog(tabId, options.clickHoldMs + 5000)
1841
+ ]);
1842
+ performPromise.catch(() => { });
1582
1843
  await this.client.inputReleaseActions({ context: tabId }).catch(() => { });
1583
1844
  }
1584
1845
  async drag(start, end, options) {
@@ -1829,16 +2090,27 @@ class BidiConnectedBrowserSession {
1829
2090
  }
1830
2091
  }
1831
2092
  async handleDialog(accept, promptText) {
1832
- const tabId = await this.getActiveTabId();
2093
+ const tabId = this.dialogTabId();
1833
2094
  if (!this.pageDialogStates.has(tabId)) {
1834
2095
  throw new McpToolError("no_dialog", "No dialog visible.");
1835
2096
  }
1836
2097
  this.pageDialogStates.delete(tabId);
1837
- await this.client.browsingContextHandleUserPrompt({
2098
+ // TODO(bidi): Firefox's browsingContext.handleUserPrompt can stall while a
2099
+ // modal is open — 实测在 alert/confirm 模态下 geckodriver 对该命令的响应会
2100
+ // 长时间不返回(实测 60s+ 不返回,最终靠 MCP 客户端超时才解脱)。这里用
2101
+ // withBiDiTimeout 兜底:最多等 5s 后强制 reject,避免工具调用无限挂起。
2102
+ // 这只是“快速失败”的缓解,并未真正解决“模态框打开时几乎所有 BiDi 命令都
2103
+ // 被卡死”的底层问题。CDP 下这一路径是可靠的,BiDi 暂只能参考 CDP 思路。
2104
+ // KNOWN-UNRESOLVED: 若在模态框打开期间调用本命令,前序的 refreshTabs
2105
+ // (browsingContextGetTree) 等也可能先一步卡死,导致整个 tool 调用超时。
2106
+ await withBiDiTimeout(this.client.browsingContextHandleUserPrompt({
1838
2107
  context: tabId,
1839
2108
  accept,
1840
2109
  ...(promptText !== undefined ? { userText: promptText } : {})
1841
- });
2110
+ }), 5_000);
2111
+ }
2112
+ async hasDialog() {
2113
+ return this.pageDialogStates.size > 0;
1842
2114
  }
1843
2115
  async networkRequests() {
1844
2116
  const tabId = await this.getActiveTabId();
@@ -1849,6 +2121,21 @@ class BidiConnectedBrowserSession {
1849
2121
  const request = this.ensureNetworkState(tabId).requests[index - 1];
1850
2122
  return request ? cloneNetworkRequest(request) : undefined;
1851
2123
  }
2124
+ async fetchResponseBody(index) {
2125
+ const tabId = await this.getActiveTabId();
2126
+ const request = this.ensureNetworkState(tabId).requests[index - 1];
2127
+ if (!request || !request.requestId) {
2128
+ return request?.responseBody;
2129
+ }
2130
+ if (request.responseBody !== undefined) {
2131
+ return request.responseBody;
2132
+ }
2133
+ const body = await this.getResponseBody(request.requestId).catch(() => undefined);
2134
+ if (body !== undefined) {
2135
+ request.responseBody = body;
2136
+ }
2137
+ return request.responseBody;
2138
+ }
1852
2139
  async runCodeUnsafe(code) {
1853
2140
  return this.evaluate(`async () => {
1854
2141
  const fn = eval(${JSON.stringify(`(${code})`)});
@@ -1864,6 +2151,15 @@ class BidiConnectedBrowserSession {
1864
2151
  }`);
1865
2152
  }
1866
2153
  async initialize() {
2154
+ // 顺序很关键:必须先 attachBiDiListeners(),再 sessionSubscribe()。
2155
+ // 实测 Firefox:sessionSubscribe 返回后事件会立即开始涌入;若此刻监听器
2156
+ // 还没注册,最早的一批 network.beforeRequestSent(页面导航/资源请求)会落进
2157
+ // “no registered listener” 分支被静默丢弃,导致 network 列表里缺首页请求。
2158
+ // 调试时观察到 [DEBUG bidi client no-listener] network.beforeRequestSent 连续
2159
+ // 出现几十次,正是此问题。先注册监听器即可避免丢事件。
2160
+ // TODO(bidi): 即便修了顺序,BiDi 网络捕获仍有状态时序问题,见
2161
+ // handleResponseCompleted / networkRequests 的 TODO。
2162
+ this.attachBiDiListeners();
1867
2163
  await this.client.sessionSubscribe({
1868
2164
  events: [
1869
2165
  "browsingContext.userPromptOpened",
@@ -1879,7 +2175,6 @@ class BidiConnectedBrowserSession {
1879
2175
  maxEncodedDataSize: 10_000_000
1880
2176
  }).catch(() => undefined);
1881
2177
  this.responseDataCollector = collectorResult?.collector;
1882
- this.attachBiDiListeners();
1883
2178
  }
1884
2179
  attachBiDiListeners() {
1885
2180
  this.attachBiDiListener("log.entryAdded", (payload) => this.handleLogEntry(payload));
@@ -1921,6 +2216,63 @@ class BidiConnectedBrowserSession {
1921
2216
  targetArg(target) {
1922
2217
  return "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
1923
2218
  }
2219
+ dialogTabId() {
2220
+ if (this.activeTabId && this.pageDialogStates.has(this.activeTabId)) {
2221
+ return this.activeTabId;
2222
+ }
2223
+ const tabId = this.pageDialogStates.keys().next().value;
2224
+ if (!tabId) {
2225
+ throw new McpToolError("no_dialog", "No dialog visible.");
2226
+ }
2227
+ return tabId;
2228
+ }
2229
+ waitForDialog(tabId, timeoutMs) {
2230
+ if (this.pageDialogStates.has(tabId)) {
2231
+ return Promise.resolve();
2232
+ }
2233
+ return new Promise((resolve, reject) => {
2234
+ const waiter = {
2235
+ resolve: () => {
2236
+ if (waiter.timer) {
2237
+ clearTimeout(waiter.timer);
2238
+ }
2239
+ this.removeDialogWaiter(tabId, waiter);
2240
+ resolve();
2241
+ },
2242
+ reject: (error) => {
2243
+ if (waiter.timer) {
2244
+ clearTimeout(waiter.timer);
2245
+ }
2246
+ this.removeDialogWaiter(tabId, waiter);
2247
+ reject(error);
2248
+ }
2249
+ };
2250
+ waiter.timer = setTimeout(() => waiter.reject?.(new Error("Timed out waiting for dialog.")), timeoutMs);
2251
+ const waiters = this.dialogWaiters.get(tabId) ?? new Set();
2252
+ waiters.add(waiter);
2253
+ this.dialogWaiters.set(tabId, waiters);
2254
+ });
2255
+ }
2256
+ resolveDialogWaiters(tabId) {
2257
+ const waiters = this.dialogWaiters.get(tabId);
2258
+ if (!waiters) {
2259
+ return;
2260
+ }
2261
+ this.dialogWaiters.delete(tabId);
2262
+ for (const waiter of waiters) {
2263
+ waiter.resolve();
2264
+ }
2265
+ }
2266
+ removeDialogWaiter(tabId, waiter) {
2267
+ const waiters = this.dialogWaiters.get(tabId);
2268
+ if (!waiters) {
2269
+ return;
2270
+ }
2271
+ waiters.delete(waiter);
2272
+ if (waiters.size === 0) {
2273
+ this.dialogWaiters.delete(tabId);
2274
+ }
2275
+ }
1924
2276
  async actionPoint(tabId, target) {
1925
2277
  const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
1926
2278
  const point = await evaluateBiDi(this.client, tabId, source, this.targetArg(target));
@@ -1981,6 +2333,11 @@ class BidiConnectedBrowserSession {
1981
2333
  type: event.type ?? "alert",
1982
2334
  ...(event.defaultValue !== undefined ? { defaultPrompt: event.defaultValue } : {})
1983
2335
  });
2336
+ // 这里必须 resolve 对话框等待器:BiDi 的 click 会 race inputPerformActions
2337
+ // 与 waitForDialog(见 click 注释)。若不在此 resolve,alert() 触发后
2338
+ // waitForDialog 会一直 pending,click 永久挂起。CDP 侧的
2339
+ // javascriptDialogOpening 处理也调用了 resolveDialogWaiters,两侧需对齐。
2340
+ this.resolveDialogWaiters(event.context);
1984
2341
  }
1985
2342
  handleBeforeRequestSent(payload) {
1986
2343
  const event = parseBidiNetworkEvent(payload);
@@ -2001,6 +2358,12 @@ class BidiConnectedBrowserSession {
2001
2358
  request.url = event.request.url;
2002
2359
  request.resourceType = normalizeResourceType(event.request.destination);
2003
2360
  request.requestHeaders = normalizeHeaders(bidiHeadersToRecord(event.request.headers));
2361
+ // TODO(bidi): BiDi 的 network.beforeRequestSent 只给 bodySize,不内联 POST
2362
+ // body。这里只置了个空串占位(requestBody ??= ""),真实 POST 体从未填充,
2363
+ // 因此 browser_network_request 的 part="request-body" 在 BiDi 下只能拿到空串。
2364
+ // CDP 侧通过 Network.requestWillBeSent 的 request.postData 直接拿到 body。
2365
+ // BiDi 若要拿到 body 需另发 network.getRequestPostData 请求(Firefox 支持不稳),
2366
+ // 暂未实现 —— 这是已知缺口,等 geckodriver 稳定后再补。
2004
2367
  if (event.request.bodySize !== undefined && event.request.bodySize > 0) {
2005
2368
  request.requestBody ??= "";
2006
2369
  }
@@ -2037,6 +2400,17 @@ class BidiConnectedBrowserSession {
2037
2400
  if (startedAt !== undefined && event.timestamp !== undefined) {
2038
2401
  request.durationMs = Math.round(event.timestamp - startedAt);
2039
2402
  }
2403
+ // TODO(bidi): BiDi 网络事件是乱序/延迟到达的,且 status 与 body 的可用时机
2404
+ // 不可靠。实测 Firefox:
2405
+ // - beforeRequestSent 与 responseCompleted 之间存在竞态,waitForNetworkRequest
2406
+ // 在 body/status 尚未就绪时就可能匹配到请求并返回;
2407
+ // - 随后再次 browser_network_requests 时,同一个 POST /api 请求有时会从列表
2408
+ // 里“消失”(疑似 responseCompleted 到达途中 ensureNetworkRequest 重建了条目
2409
+ // 或上下文切换所致,未完全定位)。
2410
+ // 这导致 BiDi 下无法像 CDP 那样做强一致的网络契约断言(=> [status] OK 全匹配)。
2411
+ // 这里仅在 responseCompleted 时尽力补 body;status 缺失的窗口由调用方容忍。
2412
+ // KNOWN-UNRESOLVED: BiDi 网络捕获的一致性问题,需等 Firefox/geckodriver 事件
2413
+ // 时序稳定后再追,或改用 network.getDataCollector 统一采集。
2040
2414
  if (canReadResponseBody(request)) {
2041
2415
  const body = await this.getResponseBody(event.request.request).catch(() => undefined);
2042
2416
  if (body !== undefined) {
@@ -2103,7 +2477,10 @@ class BidiConnectedBrowserSession {
2103
2477
  state = {
2104
2478
  requests: [],
2105
2479
  byRequestId: new Map(),
2106
- startedAt: new Map()
2480
+ startedAt: new Map(),
2481
+ hydratedPerformanceResources: false,
2482
+ loadingDone: new Map(),
2483
+ bodyRead: new Set()
2107
2484
  };
2108
2485
  this.pageNetworkStates.set(tabId, state);
2109
2486
  }
@@ -2119,7 +2496,10 @@ class BidiConnectedBrowserSession {
2119
2496
  this.pageNetworkStates.set(tabId, {
2120
2497
  requests: [],
2121
2498
  byRequestId: new Map(),
2122
- startedAt: new Map()
2499
+ startedAt: new Map(),
2500
+ hydratedPerformanceResources: false,
2501
+ loadingDone: new Map(),
2502
+ bodyRead: new Set()
2123
2503
  });
2124
2504
  this.pageDialogStates.delete(tabId);
2125
2505
  }