diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts
index 610ba1b567..07aa9a0af2 100644
--- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts
+++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts
@@ -38,6 +38,8 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
server.routePrefix('/trace', (request, response) => {
const url = new URL('http://localhost' + request.url!);
const relativePath = url.pathname.slice('/trace'.length);
+ if (relativePath.endsWith('/stall.js'))
+ return true;
if (relativePath.startsWith('/file')) {
try {
return server.serveFile(request, response, url.searchParams.get('path')!);
diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts
index b9329d0888..b85bdff2d1 100644
--- a/packages/playwright-test/src/reporters/html.ts
+++ b/packages/playwright-test/src/reporters/html.ts
@@ -181,6 +181,8 @@ export function startHtmlReportServer(folder: string): HttpServer {
return false;
}
}
+ if (relativePath.endsWith('/stall.js'))
+ return true;
if (relativePath === '/')
relativePath = '/index.html';
const absolutePath = path.join(folder, ...relativePath.split('/'));
diff --git a/packages/trace-viewer/bundle.js b/packages/trace-viewer/bundle.js
index 6a9f93b27d..cf02d28cfb 100644
--- a/packages/trace-viewer/bundle.js
+++ b/packages/trace-viewer/bundle.js
@@ -31,7 +31,8 @@ export function bundle() {
transform(html, ctx) {
if (!ctx || !ctx.bundle)
return html;
- return html.replace(/(?=/, '');
+ // Workaround vite issue that we cannot exclude some scripts from preprocessing.
+ return html.replace(/(?=/, '').replace('', '');
},
},
}
diff --git a/packages/trace-viewer/popout.html b/packages/trace-viewer/popout.html
new file mode 100644
index 0000000000..c1de1ea8ab
--- /dev/null
+++ b/packages/trace-viewer/popout.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts
index 20216a9811..86fd4a0019 100644
--- a/packages/trace-viewer/src/snapshotRenderer.ts
+++ b/packages/trace-viewer/src/snapshotRenderer.ts
@@ -180,7 +180,7 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
}
function snapshotScript() {
- function applyPlaywrightAttributes() {
+ function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string) {
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
@@ -210,7 +210,7 @@ function snapshotScript() {
iframe.setAttribute('src', 'data:text/html,');
} else {
// Append query parameters to inherit ?name= or ?time= values from parent.
- const url = new URL(window.location.href);
+ const url = new URL(unwrapPopoutUrl(window.location.href));
url.searchParams.delete('pointX');
url.searchParams.delete('pointY');
// We can be loading iframe from within iframe, reset base to be absolute.
@@ -278,7 +278,7 @@ function snapshotScript() {
window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}
- return `\n(${applyPlaywrightAttributes.toString()})()`;
+ return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()})`;
}
@@ -329,3 +329,11 @@ function rewriteURLsInStyleSheetForCustomProtocol(text: string): string {
return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
});
}
+
+// /popout.html?r= is used for "pop out snapshot" feature.
+export function unwrapPopoutUrl(url: string) {
+ const u = new URL(url);
+ if (u.pathname.endsWith('/popout.html'))
+ return u.searchParams.get('r')!;
+ return url;
+}
diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts
index e3d69fdf12..c5992d8619 100644
--- a/packages/trace-viewer/src/sw.ts
+++ b/packages/trace-viewer/src/sw.ts
@@ -15,6 +15,7 @@
*/
import { MultiMap } from './multimap';
+import { unwrapPopoutUrl } from './snapshotRenderer';
import { SnapshotServer } from './snapshotServer';
import { TraceModel } from './traceModel';
@@ -65,7 +66,7 @@ async function doFetch(event: FetchEvent): Promise {
const client = await self.clients.get(event.clientId);
if (request.url.startsWith(self.registration.scope)) {
- const url = new URL(request.url);
+ const url = new URL(unwrapPopoutUrl(request.url));
const relativePath = url.pathname.substring(scopePath.length - 1);
if (relativePath === '/ping') {
await gc();
@@ -101,7 +102,7 @@ async function doFetch(event: FetchEvent): Promise {
if (relativePath.startsWith('/snapshot/')) {
if (!snapshotServer)
return new Response(null, { status: 404 });
- return snapshotServer.serveSnapshot(relativePath, url.searchParams, request.url);
+ return snapshotServer.serveSnapshot(relativePath, url.searchParams, url.href);
}
if (relativePath.startsWith('/sha1/')) {
@@ -118,7 +119,7 @@ async function doFetch(event: FetchEvent): Promise {
return fetch(event.request);
}
- const snapshotUrl = client!.url;
+ const snapshotUrl = unwrapPopoutUrl(client!.url);
const traceUrl = new URL(snapshotUrl).searchParams.get('trace')!;
const { snapshotServer } = loadedTraces.get(traceUrl) || {};
if (!snapshotServer)
diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css
index 1bfeddd9be..b023ca3f37 100644
--- a/packages/trace-viewer/src/ui/snapshotTab.css
+++ b/packages/trace-viewer/src/ui/snapshotTab.css
@@ -63,6 +63,7 @@
flex: auto;
margin: 1px;
padding: 10px;
+ position: relative;
}
.snapshot-container {
@@ -82,6 +83,22 @@ iframe#snapshot {
padding: 50px;
}
+.popout-icon {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ color: var(--gray);
+ font-size: 14px;
+}
+
+.popout-icon:not(.popout-disabled):hover {
+ color: var(--blue);
+}
+
+.popout-icon.popout-disabled {
+ opacity: 0.7;
+}
+
.window-dot {
border-radius: 50%;
display: inline-block;
diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx
index b080e0df8b..f151cc7500 100644
--- a/packages/trace-viewer/src/ui/snapshotTab.tsx
+++ b/packages/trace-viewer/src/ui/snapshotTab.tsx
@@ -34,6 +34,7 @@ export const SnapshotTab: React.FunctionComponent<{
const snapshots = [actionSnapshot ? { ...actionSnapshot, title: 'action' } : undefined, snapshotMap.get('before'), snapshotMap.get('after')].filter(Boolean) as { title: string, snapshotName: string }[];
let snapshotUrl = 'data:text/html,';
+ let popoutUrl: string | undefined;
let snapshotInfoUrl: string | undefined;
let pointX: number | undefined;
let pointY: number | undefined;
@@ -49,6 +50,10 @@ export const SnapshotTab: React.FunctionComponent<{
pointX = action.metadata.point?.x;
pointY = action.metadata.point?.y;
}
+ const popoutParams = new URLSearchParams();
+ popoutParams.set('r', snapshotUrl);
+ popoutParams.set('trace', context(action).traceUrl);
+ popoutUrl = new URL(`popout.html?${popoutParams.toString()}`, window.location.href).toString();
}
}
@@ -107,6 +112,9 @@ export const SnapshotTab: React.FunctionComponent<{
})}
+
+
+
{ snapshots.length ?
'[name].[hash].js',
assetFileNames: () => '[name].[hash][extname]',
diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts
index c404c6bd7f..3c688e199c 100644
--- a/tests/library/trace-viewer.spec.ts
+++ b/tests/library/trace-viewer.spec.ts
@@ -223,6 +223,18 @@ test('should show snapshot URL', async ({ page, runAndTrace, server }) => {
await expect(traceViewer.page.locator('.window-address-bar')).toHaveText(server.EMPTY_PAGE);
});
+test('should popup snapshot', async ({ page, runAndTrace, server }) => {
+ const traceViewer = await runAndTrace(async () => {
+ await page.goto(server.EMPTY_PAGE);
+ await page.setContent('hello');
+ });
+ await traceViewer.snapshotFrame('page.setContent');
+ const popupPromise = traceViewer.page.context().waitForEvent('page');
+ await traceViewer.page.getByTitle('Open snapshot in a new tab').click();
+ const popup = await popupPromise;
+ await expect(popup.getByText('hello')).toBeVisible();
+});
+
test('should capture iframe with sandbox attribute', async ({ page, server, runAndTrace }) => {
await page.route('**/empty.html', route => {
route.fulfill({