Skip to content

Commit 0eefd02

Browse files
committed
fix: re-use node ids across snapshots
1 parent 56b9f76 commit 0eefd02

2 files changed

Lines changed: 26 additions & 20 deletions

File tree

src/McpContext.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {WaitForHelper} from './WaitForHelper.js';
4040
export interface TextSnapshotNode extends SerializedAXNode {
4141
id: string;
4242
backendNodeId?: number;
43+
loaderId?: string;
4344
children: TextSnapshotNode[];
4445
}
4546

@@ -129,6 +130,8 @@ export class McpContext implements Context {
129130
#locatorClass: typeof Locator;
130131
#options: McpContextOptions;
131132

133+
#uniqueBackendNodeIdToMcpId = new Map<string, string>();
134+
132135
private constructor(
133136
browser: Browser,
134137
logger: Debugger,
@@ -440,14 +443,6 @@ export class McpContext implements Context {
440443
`No snapshot found. Use ${takeSnapshot.name} to capture one.`,
441444
);
442445
}
443-
const [snapshotId] = uid.split('_');
444-
445-
if (this.#textSnapshot.snapshotId !== snapshotId) {
446-
throw new Error(
447-
'This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot.',
448-
);
449-
}
450-
451446
const node = this.#textSnapshot?.idToNode.get(uid);
452447
if (!node) {
453448
throw new Error('No such element found in the snapshot');
@@ -589,15 +584,27 @@ export class McpContext implements Context {
589584
// will be used for the tree serialization and mapping ids back to nodes.
590585
let idCounter = 0;
591586
const idToNode = new Map<string, TextSnapshotNode>();
587+
const seenUniqueIds = new Set<string>();
592588
const assignIds = (node: SerializedAXNode): TextSnapshotNode => {
593589
const nodeWithId: TextSnapshotNode = {
594590
...node,
595-
id: `${snapshotId}_${idCounter++}`,
591+
id: '', // placeholder to be set below.
596592
children: node.children
597593
? node.children.map(child => assignIds(child))
598594
: [],
599595
};
600596

597+
const uniqueBackendId = `${nodeWithId.loaderId}_${nodeWithId.backendNodeId}`;
598+
if (this.#uniqueBackendNodeIdToMcpId.has(uniqueBackendId)) {
599+
// Re-use MCP exposed ID if the uniqueId is the same.
600+
nodeWithId.id = this.#uniqueBackendNodeIdToMcpId.get(uniqueBackendId)!;
601+
} else {
602+
// Only generate a new ID if we have not seen the node before.
603+
nodeWithId.id = `${snapshotId}_${idCounter++}`;
604+
this.#uniqueBackendNodeIdToMcpId.set(uniqueBackendId, nodeWithId.id);
605+
}
606+
seenUniqueIds.add(uniqueBackendId);
607+
601608
// The AXNode for an option doesn't contain its `value`.
602609
// Therefore, set text content of the option as value.
603610
if (node.role === 'option') {
@@ -626,6 +633,13 @@ export class McpContext implements Context {
626633
data?.cdpBackendNodeId,
627634
);
628635
}
636+
637+
// Clean up unique IDs that we did not see anymore.
638+
for (const key of this.#uniqueBackendNodeIdToMcpId.keys()) {
639+
if (!seenUniqueIds.has(key)) {
640+
this.#uniqueBackendNodeIdToMcpId.delete(key);
641+
}
642+
}
629643
}
630644

631645
getTextSnapshot(): TextSnapshot | null {

tests/McpContext.test.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,16 @@ describe('McpContext', () => {
2020
await withMcpContext(async (_response, context) => {
2121
const page = context.getSelectedPage();
2222
await page.setContent(
23-
html`<button>Click me</button
24-
><input
23+
html`<button>Click me</button>
24+
<input
2525
type="text"
2626
value="Input"
2727
/>`,
2828
);
2929
await context.createTextSnapshot();
3030
assert.ok(await context.getElementByUid('1_1'));
3131
await context.createTextSnapshot();
32-
try {
33-
await context.getElementByUid('1_1');
34-
assert.fail('not reached');
35-
} catch (err) {
36-
assert.strict(
37-
err.message,
38-
'This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot',
39-
);
40-
}
32+
await context.getElementByUid('1_1');
4133
});
4234
});
4335

0 commit comments

Comments
 (0)