项目文件夹

文件
Wolfgang Beyer e046c23563 chore: in-page tool output: stash DOM elements and preprocess for serialization (#1877)
Improvements for handling in-page tool responses. In order to
successfully pass an in-page tool response from the page context to the
MCP server, the response needs to be serializable. The code walks the
response object an performs the following changes:

- DOM elements are stashed onto the window object and replaced with an
ID. On the MCP server side this ID is used to map back to the
corresponding UID in the page snapshot generated from the accessibility
tree.
- Circular references are replaced with a string.
- Class instances (which can be complex or non-serializable) are
replaced with a string.
- Functions are replaced with a string.

If the in-page tool response contains DOM elements which are not part of
the page snapshot, a new snapshot is created add the missing elements
are added explicitly.
2026-04-22 14:51:09 +00:00

302 行
9.3 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {rm, stat, mkdir, chmod, writeFile} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {describe, it} from 'node:test';
import {TextSnapshot} from '../../src/TextSnapshot.js';
import {screenshot} from '../../src/tools/screenshot.js';
import {screenshots} from '../snapshot.js';
import {html, withMcpContext} from '../utils.js';
describe('screenshot', () => {
describe('browser_take_screenshot', () => {
it('with default options', async () => {
await withMcpContext(async (response, context) => {
const fixture = screenshots.basic;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await screenshot.handler(
{params: {format: 'png'}, page: context.getSelectedMcpPage()},
response,
context,
);
assert.equal(response.images.length, 1);
assert.equal(response.images[0].mimeType, 'image/png');
assert.equal(
response.responseLines.at(0),
"Took a screenshot of the current page's viewport.",
);
});
});
it('ignores quality', async () => {
await withMcpContext(async (response, context) => {
const fixture = screenshots.basic;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await screenshot.handler(
{
params: {format: 'png', quality: 0},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.equal(response.images.length, 1);
assert.equal(response.images[0].mimeType, 'image/png');
assert.equal(
response.responseLines.at(0),
"Took a screenshot of the current page's viewport.",
);
});
});
it('with jpeg', async () => {
await withMcpContext(async (response, context) => {
await screenshot.handler(
{params: {format: 'jpeg'}, page: context.getSelectedMcpPage()},
response,
context,
);
assert.equal(response.images.length, 1);
assert.equal(response.images[0].mimeType, 'image/jpeg');
assert.equal(
response.responseLines.at(0),
"Took a screenshot of the current page's viewport.",
);
});
});
it('with webp', async () => {
await withMcpContext(async (response, context) => {
await screenshot.handler(
{params: {format: 'webp'}, page: context.getSelectedMcpPage()},
response,
context,
);
assert.equal(response.images.length, 1);
assert.equal(response.images[0].mimeType, 'image/webp');
assert.equal(
response.responseLines.at(0),
"Took a screenshot of the current page's viewport.",
);
});
});
it('with full page', async () => {
await withMcpContext(async (response, context) => {
const fixture = screenshots.viewportOverflow;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await screenshot.handler(
{
params: {format: 'png', fullPage: true},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.equal(response.images.length, 1);
assert.equal(response.images[0].mimeType, 'image/png');
assert.equal(
response.responseLines.at(0),
'Took a screenshot of the full current page.',
);
});
});
it('with full page resulting in a large screenshot', async () => {
await withMcpContext(async (response, context) => {
const page = context.getSelectedPptrPage();
await page.setContent(
html`${`<div style="color:blue;">test</div>`.repeat(6500)}
<div
id="red"
style="color:blue;"
>test</div
> `,
);
await page.evaluate(() => {
const el = document.querySelector('#red');
return el?.scrollIntoViewIfNeeded();
});
await screenshot.handler(
{
params: {format: 'png', fullPage: true},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.equal(response.images.length, 0);
assert.equal(
response.responseLines.at(0),
'Took a screenshot of the full current page.',
);
assert.ok(
response.responseLines.at(1)?.match(/Saved screenshot to.*\.png/),
);
});
});
it('with element uid', async () => {
await withMcpContext(async (response, context) => {
const fixture = screenshots.button;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create(
context.getSelectedMcpPage(),
);
await screenshot.handler(
{
params: {
format: 'png',
uid: '1_1',
},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.equal(response.images.length, 1);
assert.equal(response.images[0].mimeType, 'image/png');
assert.equal(
response.responseLines.at(0),
'Took a screenshot of node with uid "1_1".',
);
});
});
it('with filePath', async () => {
await withMcpContext(async (response, context) => {
const filePath = join(tmpdir(), 'test-screenshot.png');
try {
const fixture = screenshots.basic;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await screenshot.handler(
{
params: {format: 'png', filePath},
page: context.getSelectedMcpPage(),
},
response,
context,
);
assert.equal(response.images.length, 0);
assert.equal(
response.responseLines.at(0),
"Took a screenshot of the current page's viewport.",
);
assert.equal(
response.responseLines.at(1),
`Saved screenshot to ${filePath}.`,
);
const stats = await stat(filePath);
assert.ok(stats.isFile());
assert.ok(stats.size > 0);
} finally {
await rm(filePath, {force: true});
}
});
});
it('with unwritable filePath', async () => {
if (process.platform === 'win32') {
const filePath = join(
tmpdir(),
'readonly-file-for-screenshot-test.png',
);
// Create the file and make it read-only.
await writeFile(filePath, '');
await chmod(filePath, 0o400);
try {
await withMcpContext(async (response, context) => {
const fixture = screenshots.basic;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await assert.rejects(
screenshot.handler(
{
params: {format: 'png', filePath},
page: context.getSelectedMcpPage(),
},
response,
context,
),
);
});
} finally {
// Make the file writable again so it can be deleted.
await chmod(filePath, 0o600);
await rm(filePath, {force: true});
}
} else {
const dir = join(tmpdir(), 'readonly-dir-for-screenshot-test');
await mkdir(dir, {recursive: true});
await chmod(dir, 0o500);
const filePath = join(dir, 'test-screenshot.png');
try {
await withMcpContext(async (response, context) => {
const fixture = screenshots.basic;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await assert.rejects(
screenshot.handler(
{
params: {format: 'png', filePath},
page: context.getSelectedMcpPage(),
},
response,
context,
),
);
});
} finally {
await chmod(dir, 0o700);
await rm(dir, {recursive: true, force: true});
}
}
});
it('with malformed filePath', async () => {
await withMcpContext(async (response, context) => {
// Use a platform-specific invalid character.
// On Windows, characters like '<', '>', ':', '"', '/', '\', '|', '?', '*' are invalid.
// On POSIX, the null byte is invalid.
const invalidChar = process.platform === 'win32' ? '>' : '\0';
const filePath = `malformed${invalidChar}path.png`;
const fixture = screenshots.basic;
const page = context.getSelectedPptrPage();
await page.setContent(fixture.html);
await assert.rejects(
screenshot.handler(
{
params: {format: 'png', filePath},
page: context.getSelectedMcpPage(),
},
response,
context,
),
);
});
});
});
});