Skip to content

Commit 8ce989f

Browse files
committed
* Added scrollable container detection (findMainScrollableContainer) for dashboard layouts, and simplified the API by removing iframeUid parameter—iframes are now auto-detected when uid + fullPage are used together.
* Adding test coverage for scrollable containers, local iframes (auto-detection), and cross-origin iframe fallback behavior.
1 parent 1619bcf commit 8ce989f

3 files changed

Lines changed: 200 additions & 30 deletions

File tree

src/tools/screenshot.ts

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -438,13 +438,7 @@ export const screenshot = defineTool({
438438
.boolean()
439439
.optional()
440440
.describe(
441-
'If set to true takes a screenshot of the full page instead of the currently visible viewport. Incompatible with uid unless iframeUid is also provided.',
442-
),
443-
iframeUid: zod
444-
.string()
445-
.optional()
446-
.describe(
447-
'The uid of an iframe element. When used with fullPage=true, captures the full scrollable content of the iframe by temporarily expanding it.',
441+
'If set to true takes a screenshot of the full page instead of the currently visible viewport. When used with uid pointing to an iframe element, captures the full scrollable content of the iframe.',
448442
),
449443
filePath: zod
450444
.string()
@@ -454,38 +448,36 @@ export const screenshot = defineTool({
454448
),
455449
},
456450
handler: async (request, response, context) => {
457-
const {uid, fullPage, iframeUid} = request.params;
458-
459-
// Validate parameter combinations
460-
if (uid && fullPage && !iframeUid) {
461-
throw new Error('Providing both "uid" and "fullPage" is not allowed.');
462-
}
463-
if (uid && iframeUid) {
464-
throw new Error('Providing both "uid" and "iframeUid" is not allowed.');
465-
}
466-
if (iframeUid && !fullPage) {
467-
throw new Error(
468-
'iframeUid requires fullPage=true to capture the full iframe content.',
469-
);
470-
}
451+
const {uid, fullPage} = request.params;
471452

472453
const format = request.params.format;
473454
const quality = format === 'png' ? undefined : request.params.quality;
474455

475456
let screenshot: Uint8Array;
476457
let responseMessage: string;
477458

478-
if (iframeUid && fullPage) {
479-
// Full-page screenshot of iframe content (explicit iframe specified)
480-
const iframeHandle = await context.getElementByUid(iframeUid);
459+
if (uid && fullPage) {
460+
// Check if uid points to an iframe element
461+
const handle = await context.getElementByUid(uid);
481462
try {
482-
screenshot = await takeIframeFullPageScreenshot(iframeHandle, {
483-
type: format,
484-
quality,
485-
});
486-
responseMessage = `Took a full-page screenshot of iframe with uid "${iframeUid}".`;
463+
const isIframe = await handle.evaluate(
464+
el => el.tagName.toLowerCase() === 'iframe',
465+
);
466+
467+
if (isIframe) {
468+
// Full-page screenshot of iframe content
469+
screenshot = await takeIframeFullPageScreenshot(handle, {
470+
type: format,
471+
quality,
472+
});
473+
responseMessage = `Took a full-page screenshot of iframe with uid "${uid}".`;
474+
} else {
475+
throw new Error(
476+
'Providing both "uid" and "fullPage" is only allowed when uid points to an iframe element.',
477+
);
478+
}
487479
} finally {
488-
void iframeHandle.dispose();
480+
void handle.dispose();
489481
}
490482
} else if (uid) {
491483
// Screenshot of a specific element

tests/snapshot.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,46 @@ export const screenshots: Record<string, ScreenshotData> = {
1818
button: {
1919
html: '<button>I am button click me</button>',
2020
},
21+
scrollableContainer: {
22+
html: `
23+
<style>
24+
body { margin: 0; height: 100vh; display: flex; flex-direction: column; }
25+
.header { height: 60px; background: #333; color: white; flex-shrink: 0; }
26+
.content { flex: 1; overflow: auto; }
27+
.inner { height: 2000px; background: linear-gradient(to bottom, #f0f0f0, #333); }
28+
</style>
29+
<div class="header">Fixed Header</div>
30+
<div class="content" id="scrollable-content">
31+
<div class="inner">
32+
<p>Top of scrollable content</p>
33+
<p style="position: absolute; top: 1900px;">Bottom of scrollable content</p>
34+
</div>
35+
</div>
36+
`,
37+
},
38+
localIframe: {
39+
html: `
40+
<style>
41+
body { margin: 0; }
42+
iframe { width: 100%; height: 400px; border: none; }
43+
</style>
44+
<iframe id="local-iframe" srcdoc="
45+
<style>body { margin: 0; }</style>
46+
<div style='height: 1500px; background: linear-gradient(to bottom, #e0e0ff, #3030ff);'>
47+
<p>Top of iframe content</p>
48+
<p style='position: absolute; top: 1400px;'>Bottom of iframe content</p>
49+
</div>
50+
"></iframe>
51+
`,
52+
},
53+
crossOriginIframe: {
54+
html: `
55+
<style>
56+
body { margin: 0; }
57+
iframe { width: 100%; height: 400px; border: 1px solid #ccc; }
58+
</style>
59+
<div>Page with cross-origin iframe</div>
60+
<iframe id="cross-origin-iframe" src="https://example.com"></iframe>
61+
`,
62+
},
2163
};

tests/tools/screenshot.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,5 +260,141 @@ describe('screenshot', () => {
260260
);
261261
});
262262
});
263+
264+
describe('scrollable container', () => {
265+
it('detects and captures full content of scrollable container', async () => {
266+
await withMcpContext(async (response, context) => {
267+
const fixture = screenshots.scrollableContainer;
268+
const page = context.getSelectedPage();
269+
await page.setContent(fixture.html);
270+
271+
await screenshot.handler(
272+
{params: {format: 'png', fullPage: true}},
273+
response,
274+
context,
275+
);
276+
277+
assert.equal(response.images.length, 1);
278+
assert.equal(response.images[0].mimeType, 'image/png');
279+
assert.equal(
280+
response.responseLines.at(0),
281+
'Took a full-page screenshot of the main scrollable container.',
282+
);
283+
});
284+
});
285+
});
286+
287+
describe('local iframe', () => {
288+
it('auto-detects iframe from uid with fullPage', async () => {
289+
await withMcpContext(async (response, context) => {
290+
const fixture = screenshots.localIframe;
291+
const page = context.getSelectedPage();
292+
await page.setContent(fixture.html);
293+
294+
// Wait for iframe to load
295+
await page.waitForSelector('iframe');
296+
await new Promise(resolve => setTimeout(resolve, 100));
297+
298+
await context.createTextSnapshot();
299+
// Get the iframe uid from the snapshot
300+
const iframeUid = await page.evaluate(() => {
301+
const iframe = document.querySelector('iframe');
302+
return iframe?.getAttribute('data-elements-snapshot-uid') || '1_1';
303+
});
304+
305+
await screenshot.handler(
306+
{
307+
params: {
308+
format: 'png',
309+
fullPage: true,
310+
uid: iframeUid,
311+
},
312+
},
313+
response,
314+
context,
315+
);
316+
317+
assert.equal(response.images.length, 1);
318+
assert.equal(response.images[0].mimeType, 'image/png');
319+
assert.ok(
320+
response.responseLines.at(0)?.includes('full-page screenshot of iframe'),
321+
);
322+
});
323+
});
324+
325+
it('captures full iframe content when auto-detected', async () => {
326+
await withMcpContext(async (response, context) => {
327+
const fixture = screenshots.localIframe;
328+
const page = context.getSelectedPage();
329+
await page.setContent(fixture.html);
330+
331+
// Wait for iframe to load
332+
await page.waitForSelector('iframe');
333+
await new Promise(resolve => setTimeout(resolve, 100));
334+
335+
await screenshot.handler(
336+
{params: {format: 'png', fullPage: true}},
337+
response,
338+
context,
339+
);
340+
341+
assert.equal(response.images.length, 1);
342+
assert.equal(response.images[0].mimeType, 'image/png');
343+
assert.equal(
344+
response.responseLines.at(0),
345+
'Took a full-page screenshot of the main content iframe.',
346+
);
347+
});
348+
});
349+
350+
it('rejects uid + fullPage for non-iframe elements', async () => {
351+
await withMcpContext(async (response, context) => {
352+
const fixture = screenshots.button;
353+
const page = context.getSelectedPage();
354+
await page.setContent(fixture.html);
355+
await context.createTextSnapshot();
356+
357+
await assert.rejects(
358+
screenshot.handler(
359+
{
360+
params: {
361+
format: 'png',
362+
fullPage: true,
363+
uid: '1_1', // Points to button, not iframe
364+
},
365+
},
366+
response,
367+
context,
368+
),
369+
/only allowed when uid points to an iframe element/,
370+
);
371+
});
372+
});
373+
});
374+
375+
describe('cross-origin iframe', () => {
376+
it('falls back to regular screenshot when iframe is not accessible', async () => {
377+
await withMcpContext(async (response, context) => {
378+
const fixture = screenshots.crossOriginIframe;
379+
const page = context.getSelectedPage();
380+
await page.setContent(fixture.html);
381+
382+
// Cross-origin iframes cannot be accessed, so fullPage should fall back
383+
await screenshot.handler(
384+
{params: {format: 'png', fullPage: true}},
385+
response,
386+
context,
387+
);
388+
389+
assert.equal(response.images.length, 1);
390+
assert.equal(response.images[0].mimeType, 'image/png');
391+
// Should fall back to regular full page screenshot
392+
assert.equal(
393+
response.responseLines.at(0),
394+
'Took a screenshot of the full current page.',
395+
);
396+
});
397+
});
398+
});
263399
});
264400
});

0 commit comments

Comments
 (0)