fix: truncate data: URLs in accessibility snapshots (#3292)

## Problem

`take_snapshot` printed each node's `url` attribute in full. Images
embedded as
`data:` URLs (base64 PNG/JPEG/SVG) add up to 10,000 characters per node
(Chrome's own cap on this attribute). That data costs tokens and does
not help
the agent.

## Change

- `take_snapshot` truncates `data:` URLs in `url` attributes longer than
255
characters and appends `... <truncated>`, in both the text output and
the
  structured content.
- Other URLs (`https:`, etc.) stay whole, since the snapshot is the only
place
  where the agent can read a link's full URL.
- The 255-character limit and marker match the network request list.
Both
formatters now share a new `truncateUrl()` helper in
`src/utils/text.ts`.
  The network request list output does not change.

Accessible names are unaffected: Chrome does not derive an image's name
from
`src`. Images without `alt` keep an empty name; the URL appears only in
`url`.

## Benchmark

`take_snapshot` output on `main` vs. this branch, in characters. Savings
are
computed on the same `main` snapshot, since the change only touches
`data:`
URLs over 255 characters.

| Page | Mode | Before | After | Saved | Cause |
|---|---|---|---|---|---|
| Telerik KendoReact docs | default | 71,479 | 39,085 | 45.3% | 7 base64
SVG icons (up to 10,000 chars) |
| Figma.com home | default | 33,246 | 21,121 | 36.5% | 8 base64 PNG/JPEG
illustrations (~2,200 chars) |
| Amazon.com home | default | 70,652 | 51,192 | 27.5% | 2 base64 JPEG
banners (10,000 chars each) |
| vite.dev | default | 15,809 | 12,883 | 18.5% | 2 inline SVG data URLs
|
| W&B Weave docs | verbose | 45,722 | 44,206 | 3.3% | base64 SVG logo |

127 pages tested; 102 loaded (the rest showed bot-check pages to
headless
Chrome or timed out). The pages above changed; the others are identical.
UI libraries that render icons as inline `<svg>` (MUI, Ant Design,
Chakra,
Mantine, shadcn, Fluent) are not affected, because inline SVG markup is
not
part of the accessibility tree.

## Testing

- New test: `truncates long data url attributes in text and JSON output`
  (fails on `main`).
- New test: `keeps long non-data url attributes in full`.
- New tests for `truncateUrl()`.
- `npm run test` passes locally (macOS, Chrome for Testing 155).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
mjdch authored and GitHub committed 2026-10-21 18:42:43 +00:00
1 parent d575da4e63
commit 11e7d47495
5 files changed
+63 -4

No files matched your search

+2 -3
View File
@@ -11,10 +11,9 @@ import {
type HTTPRequest,
type HTTPResponse,
} from '../third_party/index.js';
import {truncateText} from '../utils/text.js';
import {truncateText, truncateUrl} from '../utils/text.js';
const BODY_CONTEXT_SIZE_LIMIT = 10000;
const URL_CONTEXT_SIZE_LIMIT = 255;
/**
* How long to wait for a response body. Puppeteer reads a body only after the
* response finished loading, with no timeout of its own, and a response that
@@ -311,7 +310,7 @@ function convertNetworkRequestConciseToString(
): string {
// Long URLs (e.g., data: URLs) bloat the concise list output. The full URL
// remains available via the detailed view and the structured content.
const url = getSizeLimitedString(data.url, URL_CONTEXT_SIZE_LIMIT);
const url = truncateUrl(data.url);
return `reqid=${data.requestId} ${data.method} ${url} [${data.status}]${data.selectedInDevToolsUI ? ` [selected in the DevTools Network panel]` : ''}`;
}
+4
View File
@@ -6,6 +6,7 @@
import type {TextSnapshot} from '../TextSnapshot.js';
import type {TextSnapshotNode} from '../types.js';
import {truncateUrl} from '../utils/text.js';
export class SnapshotFormatter {
#snapshot: TextSnapshot;
@@ -177,6 +178,9 @@ Get a verbose snapshot to include all elements if you are interested in the sele
if (value) {
result[attr] = true;
}
} else if (attr === 'url' && typeof value === 'string') {
// Keep other URLs whole: the snapshot is their only full copy.
result[attr] = value.startsWith('data:') ? truncateUrl(value) : value;
} else if (typeof value === 'string' || typeof value === 'number') {
result[attr] = value;
}
+9
View File
@@ -54,3 +54,12 @@ export function truncateText(
: maxLength;
return StringUtilities.truncateToCodeUnitLength(text, keep) + ellipsis;
}
const URL_SIZE_LIMIT = 255;
/**
* Truncates a URL for tool output, such as a `data:` URL with a base64 payload.
*/
export function truncateUrl(url: string): string {
return truncateText(url, URL_SIZE_LIMIT, {ellipsis: '... <truncated>'});
}
@@ -175,6 +175,41 @@ describe('snapshotFormatter', () => {
);
});
it('keeps long non-data url attributes in full', () => {
const url = 'https://example.com/?q=' + 'a'.repeat(500);
const node = {...createNode('1_1', 'link', 'Next'), url};
const formatter = new SnapshotFormatter({root: node} as TextSnapshot);
assert.strictEqual(
formatter.toString(),
`uid=1_1 link "Next" url="${url}"\n`,
);
assert.deepStrictEqual(formatter.toJSON(), {
id: '1_1',
role: 'link',
name: 'Next',
url,
});
});
it('truncates long data url attributes in text and JSON output', () => {
const url = 'data:image/png;base64,' + 'A'.repeat(5000);
const truncated = url.slice(0, 255) + '... <truncated>';
const node = {...createNode('1_1', 'image', 'logo'), url};
const formatter = new SnapshotFormatter({root: node} as TextSnapshot);
assert.strictEqual(
formatter.toString(),
`uid=1_1 image "logo" url="${truncated}"\n`,
);
assert.deepStrictEqual(formatter.toJSON(), {
id: '1_1',
role: 'image',
name: 'logo',
url: truncated,
});
});
it('formats a snapshot with value properties', () => {
const node: TextSnapshotNode = {
id: '1_1',
+13 -1
View File
@@ -7,7 +7,7 @@
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {truncateText} from '../../src/utils/text.js';
import {truncateText, truncateUrl} from '../../src/utils/text.js';
const EMOJI = '\u{1F600}';
// Family emoji: four code points joined with zero-width joiners.
@@ -75,3 +75,15 @@ describe('truncateText', () => {
assert.strictEqual(truncateText('abc', 0), '...');
});
});
describe('truncateUrl', () => {
it('returns a URL within 255 characters unchanged', () => {
const url = 'https://example.com/' + 'a'.repeat(235);
assert.strictEqual(truncateUrl(url), url);
});
it('cuts a long data URL at 255 characters', () => {
const url = 'data:image/png;base64,' + 'A'.repeat(5000);
assert.strictEqual(truncateUrl(url), url.slice(0, 255) + '... <truncated>');
});
});