Skip to content

Commit 3ca53f8

Browse files
committed
add TextSnapshot.ts
1 parent a20296c commit 3ca53f8

14 files changed

Lines changed: 467 additions & 335 deletions

src/McpContext.ts

Lines changed: 2 additions & 269 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,12 @@ import type {
2626
HTTPRequest,
2727
Page,
2828
ScreenRecorder,
29-
SerializedAXNode,
3029
Viewport,
3130
Target,
3231
Extension,
3332
} from './third_party/index.js';
34-
import type {DevTools, Protocol} from './third_party/index.js';
35-
import {Locator, type ElementHandle} from './third_party/index.js';
33+
import type {DevTools} from './third_party/index.js';
34+
import {Locator} from './third_party/index.js';
3635
import {PredefinedNetworkConditions} from './third_party/index.js';
3736
import {listPages} from './tools/pages.js';
3837
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
@@ -45,8 +44,6 @@ import type {TraceResult} from './trace-processing/parse.js';
4544
import type {
4645
EmulationSettings,
4746
GeolocationOptions,
48-
TextSnapshot,
49-
TextSnapshotNode,
5047
ExtensionServiceWorker,
5148
} from './types.js';
5249
import {ensureExtension, saveTemporaryFile} from './utils/files.js';
@@ -92,7 +89,6 @@ export class McpContext implements Context {
9289
#extensionServiceWorkerMap = new WeakMap<Target, string>();
9390
#nextExtensionServiceWorkerId = 1;
9491

95-
#nextSnapshotId = 1;
9692
#traceResults: TraceResult[] = [];
9793

9894
#locatorClass: typeof Locator;
@@ -687,269 +683,6 @@ export class McpContext implements Context {
687683
/**
688684
* Creates a text snapshot of a page.
689685
*/
690-
async createTextSnapshot(
691-
page: McpPage,
692-
verbose = false,
693-
devtoolsData: DevToolsData | undefined = undefined,
694-
extraHandles: ElementHandle[] = [],
695-
): Promise<void> {
696-
const rootNode = await page.pptrPage.accessibility.snapshot({
697-
includeIframes: true,
698-
interestingOnly: !verbose,
699-
});
700-
if (!rootNode) {
701-
return;
702-
}
703-
704-
const {uniqueBackendNodeIdToMcpId} = page;
705-
706-
const snapshotId = this.#nextSnapshotId++;
707-
// Iterate through the whole accessibility node tree and assign node ids that
708-
// will be used for the tree serialization and mapping ids back to nodes.
709-
let idCounter = 0;
710-
const idToNode = new Map<string, TextSnapshotNode>();
711-
const seenUniqueIds = new Set<string>();
712-
const seenBackendNodeIds = new Set<number>();
713-
const assignIds = (node: SerializedAXNode): TextSnapshotNode => {
714-
let id = '';
715-
// @ts-expect-error untyped backendNodeId.
716-
const backendNodeId: number = node.backendNodeId;
717-
// @ts-expect-error untyped loaderId.
718-
const uniqueBackendId = `${node.loaderId}_${backendNodeId}`;
719-
if (uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
720-
// Re-use MCP exposed ID if the uniqueId is the same.
721-
id = uniqueBackendNodeIdToMcpId.get(uniqueBackendId)!;
722-
} else {
723-
// Only generate a new ID if we have not seen the node before.
724-
id = `${snapshotId}_${idCounter++}`;
725-
uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
726-
}
727-
seenUniqueIds.add(uniqueBackendId);
728-
seenBackendNodeIds.add(backendNodeId);
729-
730-
const nodeWithId: TextSnapshotNode = {
731-
...node,
732-
id,
733-
children: node.children
734-
? node.children.map(child => assignIds(child))
735-
: [],
736-
};
737-
738-
// The AXNode for an option doesn't contain its `value`.
739-
// Therefore, set text content of the option as value.
740-
if (node.role === 'option') {
741-
const optionText = node.name;
742-
if (optionText) {
743-
nodeWithId.value = optionText.toString();
744-
}
745-
}
746-
747-
idToNode.set(nodeWithId.id, nodeWithId);
748-
return nodeWithId;
749-
};
750-
751-
const rootNodeWithId = assignIds(rootNode);
752-
753-
await this.#insertExtraNodes(
754-
page,
755-
idToNode,
756-
seenUniqueIds,
757-
snapshotId,
758-
idCounter,
759-
rootNodeWithId,
760-
seenBackendNodeIds,
761-
extraHandles,
762-
);
763-
764-
const snapshot: TextSnapshot = {
765-
root: rootNodeWithId,
766-
snapshotId: String(snapshotId),
767-
idToNode,
768-
hasSelectedElement: false,
769-
verbose,
770-
};
771-
page.textSnapshot = snapshot;
772-
const data = devtoolsData ?? (await this.getDevToolsData(page));
773-
if (data?.cdpBackendNodeId) {
774-
snapshot.hasSelectedElement = true;
775-
snapshot.selectedElementUid = page.resolveCdpElementId(
776-
data?.cdpBackendNodeId,
777-
);
778-
}
779-
780-
// Clean up unique IDs that we did not see anymore.
781-
for (const key of uniqueBackendNodeIdToMcpId.keys()) {
782-
if (!seenUniqueIds.has(key)) {
783-
uniqueBackendNodeIdToMcpId.delete(key);
784-
}
785-
}
786-
}
787-
788-
// ExtraHandles represent DOM nodes which might not be part of the accessibility tree, e.g. DOM nodes
789-
// returned by in-page tools. We insert them into the tree by finding the closest ancestor in the
790-
// tree and inserting the node as a child. The ancestor's child nodes are re-parented if necessary.
791-
async #insertExtraNodes(
792-
page: McpPage,
793-
idToNode: Map<string, TextSnapshotNode>,
794-
seenUniqueIds: Set<string>,
795-
snapshotId: number,
796-
idCounter: number,
797-
rootNodeWithId: TextSnapshotNode,
798-
seenBackendNodeIds: Set<number>,
799-
extraHandles: ElementHandle[],
800-
): Promise<void> {
801-
const {uniqueBackendNodeIdToMcpId} = page;
802-
803-
const createExtraNode = async (
804-
handle: ElementHandle,
805-
): Promise<TextSnapshotNode | null> => {
806-
const backendNodeId = await handle.backendNodeId();
807-
if (!backendNodeId || seenBackendNodeIds.has(backendNodeId)) {
808-
return null;
809-
}
810-
const uniqueBackendId = `custom_${backendNodeId}`;
811-
if (seenUniqueIds.has(uniqueBackendId)) {
812-
return null;
813-
}
814-
seenBackendNodeIds.add(backendNodeId);
815-
816-
let id = '';
817-
const mcpId = uniqueBackendNodeIdToMcpId.get(uniqueBackendId);
818-
if (mcpId !== undefined) {
819-
id = mcpId;
820-
} else {
821-
id = `${snapshotId}_${idCounter++}`;
822-
uniqueBackendNodeIdToMcpId.set(uniqueBackendId, id);
823-
}
824-
seenUniqueIds.add(uniqueBackendId);
825-
826-
const tagHandle = await handle.getProperty('localName');
827-
const tagValue = await tagHandle.jsonValue();
828-
const extraNode: TextSnapshotNode = {
829-
role: tagValue,
830-
id,
831-
backendNodeId,
832-
children: [],
833-
elementHandle: async () => handle,
834-
};
835-
return extraNode;
836-
};
837-
838-
const findAncestorNode = async (
839-
handle: ElementHandle,
840-
): Promise<TextSnapshotNode | null> => {
841-
let ancestorHandle = await handle.evaluateHandle(el => el.parentElement);
842-
843-
while (ancestorHandle) {
844-
const ancestorElement = ancestorHandle.asElement();
845-
if (!ancestorElement) {
846-
await ancestorHandle.dispose();
847-
return null;
848-
}
849-
850-
const ancestorBackendId = await ancestorElement.backendNodeId();
851-
if (ancestorBackendId) {
852-
const ancestorNode = idToNode
853-
.values()
854-
.find(node => node.backendNodeId === ancestorBackendId);
855-
if (ancestorNode) {
856-
await ancestorHandle.dispose();
857-
return ancestorNode;
858-
}
859-
}
860-
861-
const nextHandle = await ancestorElement.evaluateHandle(
862-
el => el.parentElement,
863-
);
864-
await ancestorHandle.dispose();
865-
ancestorHandle = nextHandle;
866-
}
867-
return null;
868-
};
869-
870-
const findDescendantNodes = async (
871-
backendNodeId: number,
872-
): Promise<Set<number>> => {
873-
const descendantIds = new Set<number>();
874-
try {
875-
// @ts-expect-error internal API
876-
const client = page.pptrPage._client();
877-
if (client) {
878-
const {node}: {node: Protocol.DOM.Node} = await client.send(
879-
'DOM.describeNode',
880-
{
881-
backendNodeId,
882-
depth: -1,
883-
pierce: true,
884-
},
885-
);
886-
const collect = (node: Protocol.DOM.Node) => {
887-
if (node.backendNodeId && node.backendNodeId !== backendNodeId) {
888-
descendantIds.add(node.backendNodeId);
889-
}
890-
if (node.children) {
891-
for (const child of node.children) {
892-
collect(child);
893-
}
894-
}
895-
};
896-
collect(node);
897-
}
898-
} catch (e) {
899-
this.logger(
900-
`Failed to collect descendants for backend node ${backendNodeId}`,
901-
e,
902-
);
903-
}
904-
return descendantIds;
905-
};
906-
907-
const moveChildNodes = (
908-
attachTarget: TextSnapshotNode,
909-
extraNode: TextSnapshotNode,
910-
descendantIds: Set<number>,
911-
): number => {
912-
let firstMovedIndex = -1;
913-
if (descendantIds.size > 0 && attachTarget.children) {
914-
const remainingChildren: TextSnapshotNode[] = [];
915-
for (const child of attachTarget.children) {
916-
if (child.backendNodeId && descendantIds.has(child.backendNodeId)) {
917-
if (firstMovedIndex === -1) {
918-
firstMovedIndex = remainingChildren.length;
919-
}
920-
extraNode.children.push(child);
921-
} else {
922-
remainingChildren.push(child);
923-
}
924-
}
925-
attachTarget.children = remainingChildren;
926-
}
927-
return firstMovedIndex !== -1
928-
? firstMovedIndex
929-
: attachTarget.children
930-
? attachTarget.children.length
931-
: 0;
932-
};
933-
934-
if (extraHandles.length) {
935-
page.extraHandles = extraHandles;
936-
}
937-
for (const handle of page.extraHandles) {
938-
const extraNode = await createExtraNode(handle);
939-
if (!extraNode) {
940-
continue;
941-
}
942-
idToNode.set(extraNode.id, extraNode);
943-
const attachTarget = (await findAncestorNode(handle)) || rootNodeWithId;
944-
if (extraNode.backendNodeId !== undefined) {
945-
const descendantIds = await findDescendantNodes(
946-
extraNode.backendNodeId,
947-
);
948-
const index = moveChildNodes(attachTarget, extraNode, descendantIds);
949-
attachTarget.children.splice(index, 0, extraNode);
950-
}
951-
}
952-
}
953686

954687
async saveTemporaryFile(
955688
data: Uint8Array<ArrayBufferLike>,

src/McpPage.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import {logger} from './logger.js';
8+
import {TextSnapshot} from './TextSnapshot.js';
89
import type {
910
Dialog,
1011
ElementHandle,
@@ -18,7 +19,6 @@ import type {ContextPage, Context, Response} from './tools/ToolDefinition.js';
1819
import type {
1920
EmulationSettings,
2021
GeolocationOptions,
21-
TextSnapshot,
2222
TextSnapshotNode,
2323
} from './types.js';
2424
import {
@@ -277,12 +277,9 @@ export class McpPage implements ContextPage {
277277
}
278278
const resultWithStashedElements = result.result;
279279
if (elementHandles.length) {
280-
await context.createTextSnapshot(
281-
this,
282-
false,
283-
undefined,
284-
elementHandles,
285-
);
280+
this.textSnapshot = await TextSnapshot.create(this, context, {
281+
extraHandles: elementHandles,
282+
});
286283
response.includeSnapshot();
287284
}
288285

src/McpResponse.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
1515
import type {McpContext} from './McpContext.js';
1616
import type {McpPage} from './McpPage.js';
1717
import {UncaughtError} from './PageCollector.js';
18+
import {TextSnapshot} from './TextSnapshot.js';
1819
import {DevTools, type Protocol} from './third_party/index.js';
1920
import type {
2021
ConsoleMessage,
@@ -443,11 +444,10 @@ export class McpResponse implements Response {
443444
if (!this.#page) {
444445
throw new Error('Response must have a page');
445446
}
446-
await context.createTextSnapshot(
447-
this.#page,
448-
this.#snapshotParams.verbose,
449-
this.#devToolsData,
450-
);
447+
this.#page.textSnapshot = await TextSnapshot.create(this.#page, context, {
448+
verbose: this.#snapshotParams.verbose,
449+
devtoolsData: this.#devToolsData,
450+
});
451451
const textSnapshot = this.#page.textSnapshot;
452452
if (textSnapshot) {
453453
const formatter = new SnapshotFormatter(textSnapshot);

0 commit comments

Comments
 (0)