fix: drop InlineTextBox nodes from text snapshots (#3202)

## Problem

In verbose snapshots (`interestingOnly: false`), Puppeteer serializes
`InlineTextBox` children of `StaticText` nodes. These nodes are layout
line fragments. They repeat the parent text and have no `backendNodeId`,
so no tool can target them.

They also all share one uid: the key `${loaderId}_${backendNodeId}`
resolves to `${loaderId}_undefined` for every one of them.

Example from the Wikipedia "Google Chrome" article:

```
uid=2_3 "Jump to content"
  uid=2_2658 InlineTextBox "Jump "
  uid=2_2658 InlineTextBox "to "
  uid=2_2658 InlineTextBox "content"
```

## Fix

`TextSnapshot.assignIds` filters out `InlineTextBox` children when it
builds the tree. This removes them from the text output, the JSON
output, and `idToNode`.

## Results

Wikipedia "Google Chrome" article:

| Mode | Before | After |
|---|---|---|
| Normal | 8,054 lines / 661 KB | 8,054 lines / 661 KB |
| Verbose | 27,676 lines / 2,150 KB | 18,373 lines / 1,429 KB (-34%) |

## Test

Added a real-browser test in `tests/TextSnapshot.test.ts`. The test
depends on Puppeteer's actual serialization. It fails without the fix
and passes with it.
This commit is contained in:
Alex Rudenko authored and GitHub committed 2026-10-19 06:24:49 +00:00
1 parent e08b9fa6aa
commit f8c775a7d9
3 files changed
+42 -17

No files matched your search

+12 -1
View File
@@ -14,6 +14,15 @@ import type {DevToolsData} from './tools/ToolDefinition.js';
import type {TextSnapshotNode} from './types.js';
import {logger} from './utils/logger.js';
/**
* InlineTextBox nodes are layout line fragments of a StaticText node. They
* repeat the parent text, have no backendNodeId and cannot be targeted by
* tools. Puppeteer only serializes them for non-interesting-only snapshots.
*/
function isLayoutOnlyNode(node: SerializedAXNode): boolean {
return node.role === 'InlineTextBox';
}
export class TextSnapshot {
static nextSnapshotId = 1;
@@ -94,7 +103,9 @@ export class TextSnapshot {
...node,
id,
children: node.children
? node.children.map(child => assignIds(child))
? node.children
.filter(child => !isLayoutOnlyNode(child))
.map(child => assignIds(child))
: [],
};
+1 -9
View File
@@ -405,7 +405,6 @@ uid=1_0 RootWebArea "My test page" url="about:blank"
uid=1_2 ignored
uid=1_3 complementary
uid=1_4 "test"
InlineTextBox "test"
`;
@@ -432,13 +431,7 @@ exports[`McpResponse > returns verbose snapshot and structured content 2`] = `
{
"id": "1_4",
"role": "StaticText",
"name": "test",
"children": [
{
"role": "InlineTextBox",
"name": "test"
}
]
"name": "test"
}
]
}
@@ -467,7 +460,6 @@ uid=1_0 RootWebArea "My test page" url="about:blank"
uid=1_2 ignored
uid=1_3 complementary
uid=1_4 "test"
InlineTextBox "test"
`;
+29 -7
View File
@@ -33,7 +33,7 @@ describe('TextSnapshot', () => {
loaderId: '1',
backendNodeId: 2,
children: [
createMockAXNode('InlineTextBox', {
createMockAXNode('StaticText', {
name: 'alpha',
loaderId: '1',
}),
@@ -43,7 +43,7 @@ describe('TextSnapshot', () => {
loaderId: '1',
backendNodeId: 3,
children: [
createMockAXNode('InlineTextBox', {
createMockAXNode('StaticText', {
name: 'beta',
loaderId: '1',
}),
@@ -54,21 +54,21 @@ describe('TextSnapshot', () => {
page.pptrPage.accessibility.snapshot.resolves(mockRoot);
const snapshot = await TextSnapshot.create(page, {verbose: true});
const inlineNodes = [...snapshot.idToNode.values()].filter(
node => node.role === 'InlineTextBox',
const textNodes = [...snapshot.idToNode.values()].filter(
node => node.role === 'StaticText',
);
assert.strictEqual(inlineNodes.length, 0);
assert.strictEqual(textNodes.length, 0);
const paragraph1 = snapshot.idToNode.get('1_1');
assert.strictEqual(paragraph1?.role, 'paragraph');
assert.strictEqual(paragraph1?.children[0]?.role, 'InlineTextBox');
assert.strictEqual(paragraph1?.children[0]?.role, 'StaticText');
assert.strictEqual(paragraph1?.children[0]?.id, '');
assert.strictEqual(paragraph1?.children[0]?.name, 'alpha');
const paragraph2 = snapshot.idToNode.get('1_2');
assert.strictEqual(paragraph2?.role, 'paragraph');
assert.strictEqual(paragraph2?.children[0]?.role, 'InlineTextBox');
assert.strictEqual(paragraph2?.children[0]?.role, 'StaticText');
assert.strictEqual(paragraph2?.children[0]?.id, '');
assert.strictEqual(paragraph2?.children[0]?.name, 'beta');
@@ -104,6 +104,28 @@ describe('TextSnapshot', () => {
});
});
it('drops InlineTextBox nodes from verbose snapshots', async () => {
await withMcpContext(async (_response, context) => {
const page = context.getSelectedMcpPage();
await page.pptrPage.setContent(
html`<p style="width: 40px">Some wrapped text content</p>`,
);
const snapshot = await TextSnapshot.create(page, {verbose: true});
let staticTextNode: TextSnapshotNode | undefined;
for (const node of snapshot.idToNode.values()) {
assert.notStrictEqual(node.role, 'InlineTextBox');
if (node.role === 'StaticText') {
staticTextNode = node;
}
}
assert.ok(staticTextNode, 'StaticText node should be in the snapshot');
assert.strictEqual(staticTextNode.name, 'Some wrapped text content');
assert.deepStrictEqual(staticTextNode.children, []);
});
});
it('inserts extraHandles into the snapshot correctly', async () => {
await withMcpContext(async (_response, context) => {
const page = context.getSelectedMcpPage();