mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 05:02:31 +00:00
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:
1 parent
d575da4e63
commit
11e7d47495
5 files changed
+63
-4
No files matched your search
@@ -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]` : ''}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user