From b45f02c37e42dcfd4c0a6ca43b9403e337b3aab6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 4 May 2026 11:02:44 +0200 Subject: [PATCH 1/2] fix(trace): resolve iframe URLs in trace snapshot CLI The `trace snapshot` HTTP server hardcoded the top-level pageId for every request and routed `/snapshot` (no trailing slash), so iframe sub-frame requests returned the parent page's snapshot and the renderer's iframe rewrite kept concatenating frame paths instead of finding the prefix. Mirror the trace-viewer service worker: route `/snapshot/`, parse the page-or-frame id from the path, and pass the full URL as the snapshot cache key. Fixes https://github.com/microsoft/playwright/issues/40533 --- .../src/tools/trace/traceSnapshot.ts | 7 ++--- tests/mcp/trace-cli-fixtures.ts | 26 +++++++++++++++++-- tests/mcp/trace-cli.spec.ts | 10 +++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/playwright-core/src/tools/trace/traceSnapshot.ts b/packages/playwright-core/src/tools/trace/traceSnapshot.ts index df417abc424b6..ccac7fb5a2dcd 100644 --- a/packages/playwright-core/src/tools/trace/traceSnapshot.ts +++ b/packages/playwright-core/src/tools/trace/traceSnapshot.ts @@ -87,11 +87,12 @@ async function serveTraceSnapshot(storage: SnapshotStorage, loader: TraceLoader, const snapshotServer = new SnapshotServer(storage, sha1 => loader.resourceForSha1(sha1)); const httpServer = new HttpServer(); - httpServer.routePrefix('/snapshot', (request: any, response: any) => { + httpServer.routePrefix('/snapshot/', (request: any, response: any) => { const url = new URL('http://localhost' + request.url!); + const pageOrFrameId = url.pathname.substring('/snapshot/'.length); const searchParams = url.searchParams; searchParams.set('name', snapshotKey); - const snapshotResponse = snapshotServer.serveSnapshot(pageId, searchParams, '/snapshot'); + const snapshotResponse = snapshotServer.serveSnapshot(pageOrFrameId, searchParams, url.href); response.statusCode = snapshotResponse.status; snapshotResponse.headers.forEach((value: string, key: string) => response.setHeader(key, value)); snapshotResponse.text().then((text: string) => response.end(text)); @@ -100,7 +101,7 @@ async function serveTraceSnapshot(storage: SnapshotStorage, loader: TraceLoader, httpServer.routePrefix('/', (_request: any, response: any) => { response.statusCode = 302; - response.setHeader('Location', '/snapshot'); + response.setHeader('Location', `/snapshot/${pageId}?name=${encodeURIComponent(snapshotKey)}`); response.end(); return true; }); diff --git a/tests/mcp/trace-cli-fixtures.ts b/tests/mcp/trace-cli-fixtures.ts index c04fd048a224c..647991b855697 100644 --- a/tests/mcp/trace-cli-fixtures.ts +++ b/tests/mcp/trace-cli-fixtures.ts @@ -57,7 +57,27 @@ export const test = baseTest server.setContent('/page2', ` Page 2 -

Page 2

+ +

Page 2

+ + + + `, 'text/html'); + + server.setContent('/iframe', ` + + Iframe + +

Iframe content

+ + + + `, 'text/html'); + + server.setContent('/iframe-inner', ` + + Inner iframe +

Innermost

`, 'text/html'); @@ -84,7 +104,9 @@ export const test = baseTest // Navigate to another page await page.locator('a').click(); - await page.waitForURL('**/page2'); + + // Click into innermost frame + await page.frameLocator('#frame1').frameLocator('#frame2').locator('p').click(); await page.close(); const tmpDir = path.join(workerInfo.project.outputDir, 'pw-trace-cli-' + workerInfo.workerIndex); diff --git a/tests/mcp/trace-cli.spec.ts b/tests/mcp/trace-cli.spec.ts index 82668761d5fe1..3bc4c01fd08b0 100644 --- a/tests/mcp/trace-cli.spec.ts +++ b/tests/mcp/trace-cli.spec.ts @@ -175,6 +175,16 @@ test('trace snapshot --name before', async ({ runTraceCli }) => { expect(stdout).toBeTruthy(); }); +test('trace snapshot resolves inner frames', async ({ runTraceCli }) => { + const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Click']); + const ordinals = [...listOutput.matchAll(/^\s+(\d+)\.\s/gm)].map(m => m[1]); + expect(ordinals.length).toBeGreaterThanOrEqual(2); + const anchorClickOrdinal = ordinals[ordinals.length - 1]; + + const { stdout } = await runTraceCli(['snapshot', '--name', 'after', anchorClickOrdinal]); + expect(stdout).toContain('Innermost'); +}); + test('trace screenshot saves image file', async ({ runTraceCli }, testInfo) => { const { stdout: listOutput } = await runTraceCli(['actions', '--grep', 'Navigate']); const match = listOutput.match(/^\s+(\d+)\.\s/m); From 49aa8c8d086288df1d6716162e8b75a38681a105 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 4 May 2026 12:18:00 +0200 Subject: [PATCH 2/2] fix(trace-viewer): show ellipsis and copy full Return value Previously the Call tab silently truncated object Return values to 1000 chars, and the copy-to-clipboard button copied the truncated string too. Move the truncation to the render layer so the displayed text shows an ellipsis but the copy button receives the full value. Fixes https://github.com/microsoft/playwright/issues/40527 --- packages/trace-viewer/src/ui/callTab.tsx | 9 ++++++--- tests/library/trace-viewer.spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/trace-viewer/src/ui/callTab.tsx b/packages/trace-viewer/src/ui/callTab.tsx index 63ca7f567a4b9..69da4e94c1089 100644 --- a/packages/trace-viewer/src/ui/callTab.tsx +++ b/packages/trace-viewer/src/ui/callTab.tsx @@ -84,12 +84,15 @@ function renderDuration(action: ActionTraceEventInContext): string { } function renderProperty(property: Property) { - let text = property.text.replace(/\n/g, '↵'); + let text = property.text; + if (text.length > 1000) + text = text.slice(0, 1000) + '…'; + text = text.replace(/\n/g, '↵'); if (property.type === 'string') text = `"${text}"`; return (
- {property.name}:{text} + {property.name}:{text} { ['literal', 'string', 'number', 'object', 'locator'].includes(property.type) && } @@ -121,7 +124,7 @@ function propertyToString(event: ActionTraceEvent, name: string, value: any, sdk return { text: String(value), type, name }; if (value.guid) return { text: '', type: 'handle', name }; - return { text: JSON.stringify(value).slice(0, 1000), type: 'object', name }; + return { text: JSON.stringify(value), type: 'object', name }; } function parseSerializedValue(value: SerializedValue, handles: any[] | undefined): any { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index c0b030fc9079f..c415e299096b8 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -389,6 +389,28 @@ test('should show null as a param', async ({ showTraceViewer, browserName }) => ]); }); +test('should truncate long return values with ellipsis but copy full value', async ({ page, runAndTrace }) => { + test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40527' }); + const traceViewer = await runAndTrace(async () => { + await page.evaluate(() => { + const value: Record = {}; + for (let i = 0; i < 100; i++) + value['key_' + i] = 'value_' + i + '_padding_padding_padding'; + return value; + }); + }); + await traceViewer.selectAction('Evaluate'); + const returnValue = traceViewer.callLines.filter({ hasText: 'value:' }); + await expect(returnValue.locator('.call-value')).toHaveText(/…$/); + + await traceViewer.page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + await returnValue.hover(); + await returnValue.getByRole('button', { name: 'Copy' }).click(); + const copied = await traceViewer.page.evaluate(() => navigator.clipboard.readText()); + expect(copied.length).toBeGreaterThan(1000); + expect(copied.endsWith('…')).toBe(false); +}); + test('should have correct snapshot size', async ({ showTraceViewer }, testInfo) => { const traceViewer = await showTraceViewer(traceFile); await traceViewer.selectAction('SET VIEWPORT');