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