@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.
- package/dist/browser.d.ts +33 -5
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +127 -15
- package/dist/browser.js.map +1 -1
- package/dist/browserType.d.ts +23 -1
- package/dist/browserType.d.ts.map +1 -1
- package/dist/browserType.js +29 -5
- package/dist/browserType.js.map +1 -1
- package/dist/mcp/backend/network.d.ts.map +1 -1
- package/dist/mcp/backend/network.js +30 -4
- package/dist/mcp/backend/network.js.map +1 -1
- package/dist/mcp/backend/response.d.ts +2 -0
- package/dist/mcp/backend/response.d.ts.map +1 -1
- package/dist/mcp/backend/response.js +28 -4
- package/dist/mcp/backend/response.js.map +1 -1
- package/dist/mcp/connectedBrowser.d.ts.map +1 -1
- package/dist/mcp/connectedBrowser.js +402 -22
- package/dist/mcp/connectedBrowser.js.map +1 -1
- package/dist/mcp/runtime.d.ts +2 -0
- package/dist/mcp/runtime.d.ts.map +1 -1
- package/dist/mcp/runtime.js +20 -1
- package/dist/mcp/runtime.js.map +1 -1
- package/dist/mcp/types.d.ts +3 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/page.d.ts +2 -0
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +12 -0
- package/dist/page.js.map +1 -1
- package/dist/roxybrowser.bundle.js +454 -42
- package/dist/roxybrowser.bundle.js.map +1 -1
- package/dist/types/api.d.ts +23 -4
- package/dist/types/api.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|