@roxybrowser/playwright 2.0.2-beta.0 → 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.
- package/README.md +85 -7
- package/dist/bin/roxybrowser-mcp.js +72 -2
- package/dist/bin/roxybrowser-mcp.js.map +1 -1
- package/dist/browser.d.ts +33 -5
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +134 -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 +33 -5
- package/dist/browserType.js.map +1 -1
- package/dist/mcp/backend/connect.d.ts +1 -0
- package/dist/mcp/backend/connect.d.ts.map +1 -1
- package/dist/mcp/backend/connect.js +4 -2
- package/dist/mcp/backend/connect.js.map +1 -1
- package/dist/mcp/backend/context.d.ts +3 -0
- package/dist/mcp/backend/context.d.ts.map +1 -1
- package/dist/mcp/backend/context.js +9 -1
- package/dist/mcp/backend/context.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 +53 -7
- package/dist/mcp/backend/response.js.map +1 -1
- package/dist/mcp/connectedBrowser.d.ts.map +1 -1
- package/dist/mcp/connectedBrowser.js +494 -37
- package/dist/mcp/connectedBrowser.js.map +1 -1
- package/dist/mcp/output.d.ts +5 -0
- package/dist/mcp/output.d.ts.map +1 -1
- package/dist/mcp/output.js +17 -1
- package/dist/mcp/output.js.map +1 -1
- package/dist/mcp/runtime.d.ts +7 -0
- package/dist/mcp/runtime.d.ts.map +1 -1
- package/dist/mcp/runtime.js +84 -9
- package/dist/mcp/runtime.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +3 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/transports/inMemory.d.ts.map +1 -1
- package/dist/mcp/transports/inMemory.js +1 -0
- package/dist/mcp/transports/inMemory.js.map +1 -1
- package/dist/mcp/types.d.ts +7 -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/protocol/adapter.d.ts +1 -0
- package/dist/protocol/adapter.d.ts.map +1 -1
- package/dist/protocol/bidi/backend.d.ts +1 -1
- package/dist/protocol/bidi/backend.d.ts.map +1 -1
- package/dist/protocol/bidi/backend.js +6 -2
- package/dist/protocol/bidi/backend.js.map +1 -1
- package/dist/protocol/bidi/client.d.ts +1 -0
- package/dist/protocol/bidi/client.d.ts.map +1 -1
- package/dist/protocol/bidi/client.js +48 -1
- package/dist/protocol/bidi/client.js.map +1 -1
- package/dist/protocol/cdp/backend.d.ts.map +1 -1
- package/dist/protocol/cdp/backend.js +122 -8
- package/dist/protocol/cdp/backend.js.map +1 -1
- package/dist/roxybrowser.bundle.js +1060 -467
- 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 +11 -4
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 `${
|
|
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,11 +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;
|
|
1651
|
+
tempDir;
|
|
1652
|
+
constructor(client, tempDir) {
|
|
1388
1653
|
this.client = client;
|
|
1654
|
+
this.tempDir = configuredTempDir({
|
|
1655
|
+
...(tempDir !== undefined ? { tempDir } : {})
|
|
1656
|
+
});
|
|
1389
1657
|
}
|
|
1390
1658
|
static async connect(args) {
|
|
1391
1659
|
if (args.browser && args.browser !== "firefox") {
|
|
@@ -1397,14 +1665,12 @@ class BidiConnectedBrowserSession {
|
|
|
1397
1665
|
}
|
|
1398
1666
|
const client = await getBidiClientFactory()({
|
|
1399
1667
|
browserName: "firefox",
|
|
1400
|
-
webSocketUrl: args.endpoint
|
|
1668
|
+
webSocketUrl: normalizeFirefoxBidiEndpoint(args.endpoint, args.sessionId)
|
|
1401
1669
|
});
|
|
1402
|
-
const session = new BidiConnectedBrowserSession(client);
|
|
1670
|
+
const session = new BidiConnectedBrowserSession(client, args.tempDir);
|
|
1671
|
+
session.ownsSession = await ensureMcpBiDiSession(client, args.endpoint, args.sessionId);
|
|
1403
1672
|
await session.initialize();
|
|
1404
|
-
|
|
1405
|
-
if (tabs.length === 0) {
|
|
1406
|
-
await session.newTab();
|
|
1407
|
-
}
|
|
1673
|
+
await session.refreshTabs();
|
|
1408
1674
|
return session;
|
|
1409
1675
|
}
|
|
1410
1676
|
async version() {
|
|
@@ -1462,11 +1728,15 @@ class BidiConnectedBrowserSession {
|
|
|
1462
1728
|
}
|
|
1463
1729
|
async snapshot(request = {}) {
|
|
1464
1730
|
const tabId = await this.getActiveTabId();
|
|
1465
|
-
const result = await evaluateBiDi(this.client, tabId, ARIA_SNAPSHOT_EVALUATE_SOURCE, toAriaSnapshotPayload(request));
|
|
1466
|
-
|
|
1731
|
+
const result = await retryUntilReady(() => evaluateBiDi(this.client, tabId, ARIA_SNAPSHOT_EVALUATE_SOURCE, toAriaSnapshotPayload(request)));
|
|
1732
|
+
const snapshot = toBrowserSnapshot(result, request, {
|
|
1467
1733
|
console: this.consoleSummary(tabId),
|
|
1468
1734
|
consoleLink: await this.takeConsoleLink(tabId)
|
|
1469
1735
|
});
|
|
1736
|
+
return {
|
|
1737
|
+
...snapshot,
|
|
1738
|
+
retryable: true
|
|
1739
|
+
};
|
|
1470
1740
|
}
|
|
1471
1741
|
async consoleMessages(level = "info", all = false) {
|
|
1472
1742
|
const activeTabId = await this.getActiveTabId();
|
|
@@ -1556,10 +1826,28 @@ class BidiConnectedBrowserSession {
|
|
|
1556
1826
|
parameters: { pointerType: "mouse" },
|
|
1557
1827
|
actions: pointerActions
|
|
1558
1828
|
});
|
|
1559
|
-
|
|
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({
|
|
1560
1843
|
context: tabId,
|
|
1561
1844
|
actions
|
|
1562
1845
|
});
|
|
1846
|
+
await Promise.race([
|
|
1847
|
+
performPromise,
|
|
1848
|
+
this.waitForDialog(tabId, options.clickHoldMs + 5000)
|
|
1849
|
+
]);
|
|
1850
|
+
performPromise.catch(() => { });
|
|
1563
1851
|
await this.client.inputReleaseActions({ context: tabId }).catch(() => { });
|
|
1564
1852
|
}
|
|
1565
1853
|
async drag(start, end, options) {
|
|
@@ -1647,7 +1935,9 @@ class BidiConnectedBrowserSession {
|
|
|
1647
1935
|
await this.client.networkRemoveDataCollector({ collector: this.responseDataCollector }).catch(() => { });
|
|
1648
1936
|
this.responseDataCollector = undefined;
|
|
1649
1937
|
}
|
|
1650
|
-
|
|
1938
|
+
if (this.ownsSession) {
|
|
1939
|
+
await this.client.sessionEnd({}).catch(() => { });
|
|
1940
|
+
}
|
|
1651
1941
|
this.client.close();
|
|
1652
1942
|
}
|
|
1653
1943
|
async navigate(url) {
|
|
@@ -1808,16 +2098,27 @@ class BidiConnectedBrowserSession {
|
|
|
1808
2098
|
}
|
|
1809
2099
|
}
|
|
1810
2100
|
async handleDialog(accept, promptText) {
|
|
1811
|
-
const tabId =
|
|
2101
|
+
const tabId = this.dialogTabId();
|
|
1812
2102
|
if (!this.pageDialogStates.has(tabId)) {
|
|
1813
2103
|
throw new McpToolError("no_dialog", "No dialog visible.");
|
|
1814
2104
|
}
|
|
1815
2105
|
this.pageDialogStates.delete(tabId);
|
|
1816
|
-
|
|
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({
|
|
1817
2115
|
context: tabId,
|
|
1818
2116
|
accept,
|
|
1819
2117
|
...(promptText !== undefined ? { userText: promptText } : {})
|
|
1820
|
-
});
|
|
2118
|
+
}), 5_000);
|
|
2119
|
+
}
|
|
2120
|
+
async hasDialog() {
|
|
2121
|
+
return this.pageDialogStates.size > 0;
|
|
1821
2122
|
}
|
|
1822
2123
|
async networkRequests() {
|
|
1823
2124
|
const tabId = await this.getActiveTabId();
|
|
@@ -1828,6 +2129,21 @@ class BidiConnectedBrowserSession {
|
|
|
1828
2129
|
const request = this.ensureNetworkState(tabId).requests[index - 1];
|
|
1829
2130
|
return request ? cloneNetworkRequest(request) : undefined;
|
|
1830
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
|
+
}
|
|
1831
2147
|
async runCodeUnsafe(code) {
|
|
1832
2148
|
return this.evaluate(`async () => {
|
|
1833
2149
|
const fn = eval(${JSON.stringify(`(${code})`)});
|
|
@@ -1843,6 +2159,15 @@ class BidiConnectedBrowserSession {
|
|
|
1843
2159
|
}`);
|
|
1844
2160
|
}
|
|
1845
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();
|
|
1846
2171
|
await this.client.sessionSubscribe({
|
|
1847
2172
|
events: [
|
|
1848
2173
|
"browsingContext.userPromptOpened",
|
|
@@ -1858,7 +2183,6 @@ class BidiConnectedBrowserSession {
|
|
|
1858
2183
|
maxEncodedDataSize: 10_000_000
|
|
1859
2184
|
}).catch(() => undefined);
|
|
1860
2185
|
this.responseDataCollector = collectorResult?.collector;
|
|
1861
|
-
this.attachBiDiListeners();
|
|
1862
2186
|
}
|
|
1863
2187
|
attachBiDiListeners() {
|
|
1864
2188
|
this.attachBiDiListener("log.entryAdded", (payload) => this.handleLogEntry(payload));
|
|
@@ -1900,6 +2224,63 @@ class BidiConnectedBrowserSession {
|
|
|
1900
2224
|
targetArg(target) {
|
|
1901
2225
|
return "nodeToken" in target ? { nodeToken: target.nodeToken } : { selector: target.selector };
|
|
1902
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
|
+
}
|
|
1903
2284
|
async actionPoint(tabId, target) {
|
|
1904
2285
|
const source = "nodeToken" in target ? ACTION_POINT_EVALUATE_SOURCE : ACTION_POINT_BY_SELECTOR_SOURCE;
|
|
1905
2286
|
const point = await evaluateBiDi(this.client, tabId, source, this.targetArg(target));
|
|
@@ -1960,6 +2341,11 @@ class BidiConnectedBrowserSession {
|
|
|
1960
2341
|
type: event.type ?? "alert",
|
|
1961
2342
|
...(event.defaultValue !== undefined ? { defaultPrompt: event.defaultValue } : {})
|
|
1962
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);
|
|
1963
2349
|
}
|
|
1964
2350
|
handleBeforeRequestSent(payload) {
|
|
1965
2351
|
const event = parseBidiNetworkEvent(payload);
|
|
@@ -1980,6 +2366,12 @@ class BidiConnectedBrowserSession {
|
|
|
1980
2366
|
request.url = event.request.url;
|
|
1981
2367
|
request.resourceType = normalizeResourceType(event.request.destination);
|
|
1982
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 稳定后再补。
|
|
1983
2375
|
if (event.request.bodySize !== undefined && event.request.bodySize > 0) {
|
|
1984
2376
|
request.requestBody ??= "";
|
|
1985
2377
|
}
|
|
@@ -2016,6 +2408,17 @@ class BidiConnectedBrowserSession {
|
|
|
2016
2408
|
if (startedAt !== undefined && event.timestamp !== undefined) {
|
|
2017
2409
|
request.durationMs = Math.round(event.timestamp - startedAt);
|
|
2018
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 统一采集。
|
|
2019
2422
|
if (canReadResponseBody(request)) {
|
|
2020
2423
|
const body = await this.getResponseBody(event.request.request).catch(() => undefined);
|
|
2021
2424
|
if (body !== undefined) {
|
|
@@ -2082,7 +2485,10 @@ class BidiConnectedBrowserSession {
|
|
|
2082
2485
|
state = {
|
|
2083
2486
|
requests: [],
|
|
2084
2487
|
byRequestId: new Map(),
|
|
2085
|
-
startedAt: new Map()
|
|
2488
|
+
startedAt: new Map(),
|
|
2489
|
+
hydratedPerformanceResources: false,
|
|
2490
|
+
loadingDone: new Map(),
|
|
2491
|
+
bodyRead: new Set()
|
|
2086
2492
|
};
|
|
2087
2493
|
this.pageNetworkStates.set(tabId, state);
|
|
2088
2494
|
}
|
|
@@ -2098,7 +2504,10 @@ class BidiConnectedBrowserSession {
|
|
|
2098
2504
|
this.pageNetworkStates.set(tabId, {
|
|
2099
2505
|
requests: [],
|
|
2100
2506
|
byRequestId: new Map(),
|
|
2101
|
-
startedAt: new Map()
|
|
2507
|
+
startedAt: new Map(),
|
|
2508
|
+
hydratedPerformanceResources: false,
|
|
2509
|
+
loadingDone: new Map(),
|
|
2510
|
+
bodyRead: new Set()
|
|
2102
2511
|
});
|
|
2103
2512
|
this.pageDialogStates.delete(tabId);
|
|
2104
2513
|
}
|
|
@@ -2129,7 +2538,7 @@ class BidiConnectedBrowserSession {
|
|
|
2129
2538
|
if (messages.length === 0) {
|
|
2130
2539
|
return undefined;
|
|
2131
2540
|
}
|
|
2132
|
-
state.logFile ??= path.join(
|
|
2541
|
+
state.logFile ??= path.join(this.tempDir, `console-${new Date(state.logStartTime).toISOString().replace(/[:.]/g, "-")}.log`);
|
|
2133
2542
|
await mkdir(path.dirname(state.logFile), { recursive: true });
|
|
2134
2543
|
const fromLine = state.logLine + 1;
|
|
2135
2544
|
for (const message of messages) {
|
|
@@ -2139,11 +2548,59 @@ class BidiConnectedBrowserSession {
|
|
|
2139
2548
|
state.logLine += logLine.split("\n").length - 1;
|
|
2140
2549
|
}
|
|
2141
2550
|
state.nextMessageIndex = state.messages.length;
|
|
2142
|
-
const relativePath = path.relative(process.cwd(), state.logFile);
|
|
2143
2551
|
const lineRange = fromLine === state.logLine ? `#L${fromLine}` : `#L${fromLine}-L${state.logLine}`;
|
|
2144
|
-
return `${
|
|
2552
|
+
return `${state.logFile}${lineRange}`;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
function normalizeFirefoxBidiEndpoint(endpoint, sessionId) {
|
|
2556
|
+
const url = new URL(endpoint);
|
|
2557
|
+
if (sessionId) {
|
|
2558
|
+
url.pathname = `/session/${sessionId}`;
|
|
2559
|
+
return url.toString();
|
|
2560
|
+
}
|
|
2561
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
2562
|
+
url.pathname = "/session";
|
|
2563
|
+
}
|
|
2564
|
+
return url.toString();
|
|
2565
|
+
}
|
|
2566
|
+
async function ensureMcpBiDiSession(client, endpoint, sessionId) {
|
|
2567
|
+
await client.sessionStatus({});
|
|
2568
|
+
if (sessionId || isSessionSpecificFirefoxBidiEndpoint(endpoint)) {
|
|
2569
|
+
return false;
|
|
2570
|
+
}
|
|
2571
|
+
try {
|
|
2572
|
+
await client.browsingContextGetTree({});
|
|
2573
|
+
return false;
|
|
2574
|
+
}
|
|
2575
|
+
catch (error) {
|
|
2576
|
+
const message = String(error instanceof Error ? error.message : error);
|
|
2577
|
+
if (!message.includes("session does not exist")
|
|
2578
|
+
&& !message.includes("invalid session id")
|
|
2579
|
+
&& !message.includes("not active")) {
|
|
2580
|
+
throw error;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
try {
|
|
2584
|
+
await client.sessionNew({
|
|
2585
|
+
capabilities: {
|
|
2586
|
+
alwaysMatch: {
|
|
2587
|
+
acceptInsecureCerts: true
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
});
|
|
2591
|
+
return true;
|
|
2592
|
+
}
|
|
2593
|
+
catch (error) {
|
|
2594
|
+
const message = String(error instanceof Error ? error.message : error);
|
|
2595
|
+
if (message.includes("Maximum number of active sessions")) {
|
|
2596
|
+
throw new Error("Maximum number of active BiDi sessions. Reuse an existing one with sessionId or close the current session first.");
|
|
2597
|
+
}
|
|
2598
|
+
throw error;
|
|
2145
2599
|
}
|
|
2146
2600
|
}
|
|
2601
|
+
function isSessionSpecificFirefoxBidiEndpoint(endpoint) {
|
|
2602
|
+
return /^\/session\/[^/]+$/.test(new URL(endpoint).pathname);
|
|
2603
|
+
}
|
|
2147
2604
|
export async function connectBrowserSession(args) {
|
|
2148
2605
|
if (args.protocol === "cdp") {
|
|
2149
2606
|
return CdpConnectedBrowserSession.connect(args);
|