feat(trace viewer): popout snapshot in a new tab (#20475)

This commit is contained in:
Dmitry Gozman 2023-01-30 19:07:52 -08:00 committed by GitHub
parent a03f3223c4
commit b39079b51e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 95 additions and 7 deletions

View File

@ -38,6 +38,8 @@ export async function showTraceViewer(traceUrls: string[], browserName: string,
server.routePrefix('/trace', (request, response) => { server.routePrefix('/trace', (request, response) => {
const url = new URL('http://localhost' + request.url!); const url = new URL('http://localhost' + request.url!);
const relativePath = url.pathname.slice('/trace'.length); const relativePath = url.pathname.slice('/trace'.length);
if (relativePath.endsWith('/stall.js'))
return true;
if (relativePath.startsWith('/file')) { if (relativePath.startsWith('/file')) {
try { try {
return server.serveFile(request, response, url.searchParams.get('path')!); return server.serveFile(request, response, url.searchParams.get('path')!);

View File

@ -181,6 +181,8 @@ export function startHtmlReportServer(folder: string): HttpServer {
return false; return false;
} }
} }
if (relativePath.endsWith('/stall.js'))
return true;
if (relativePath === '/') if (relativePath === '/')
relativePath = '/index.html'; relativePath = '/index.html';
const absolutePath = path.join(folder, ...relativePath.split('/')); const absolutePath = path.join(folder, ...relativePath.split('/'));

View File

@ -31,7 +31,8 @@ export function bundle() {
transform(html, ctx) { transform(html, ctx) {
if (!ctx || !ctx.bundle) if (!ctx || !ctx.bundle)
return html; return html;
return html.replace(/(?=<!--)([\s\S]*?)-->/, ''); // Workaround vite issue that we cannot exclude some scripts from preprocessing.
return html.replace(/(?=<!--)([\s\S]*?)-->/, '').replace('<!-- <script src="stall.js"></script> -->', '<script src="stall.js"></script>');
}, },
}, },
} }

View File

@ -0,0 +1,33 @@
<!--
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<body>
<script>
(async () => {
navigator.serviceWorker.register('sw.bundle.js');
if (!navigator.serviceWorker.controller)
await new Promise(f => navigator.serviceWorker.oncontrollerchange = f);
const traceUrl = new URL(location.href).searchParams.get('trace');
const params = new URLSearchParams();
params.set('trace', traceUrl);
await fetch('context?' + params.toString()).then(r => r.json());
await location.reload();
})();
</script>
<!-- <script src="stall.js"></script> -->
</body>
</html>

View File

@ -180,7 +180,7 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
} }
function snapshotScript() { function snapshotScript() {
function applyPlaywrightAttributes() { function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string) {
const scrollTops: Element[] = []; const scrollTops: Element[] = [];
const scrollLefts: Element[] = []; const scrollLefts: Element[] = [];
@ -210,7 +210,7 @@ function snapshotScript() {
iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>'); iframe.setAttribute('src', 'data:text/html,<body style="background: #ddd"></body>');
} else { } else {
// Append query parameters to inherit ?name= or ?time= values from parent. // 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('pointX');
url.searchParams.delete('pointY'); url.searchParams.delete('pointY');
// We can be loading iframe from within iframe, reset base to be absolute. // We can be loading iframe from within iframe, reset base to be absolute.
@ -278,7 +278,7 @@ function snapshotScript() {
window.addEventListener('DOMContentLoaded', onDOMContentLoaded); 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)}--`); return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`);
}); });
} }
// <base>/popout.html?r=<snapshotUrl> 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;
}

View File

@ -15,6 +15,7 @@
*/ */
import { MultiMap } from './multimap'; import { MultiMap } from './multimap';
import { unwrapPopoutUrl } from './snapshotRenderer';
import { SnapshotServer } from './snapshotServer'; import { SnapshotServer } from './snapshotServer';
import { TraceModel } from './traceModel'; import { TraceModel } from './traceModel';
@ -65,7 +66,7 @@ async function doFetch(event: FetchEvent): Promise<Response> {
const client = await self.clients.get(event.clientId); const client = await self.clients.get(event.clientId);
if (request.url.startsWith(self.registration.scope)) { 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); const relativePath = url.pathname.substring(scopePath.length - 1);
if (relativePath === '/ping') { if (relativePath === '/ping') {
await gc(); await gc();
@ -101,7 +102,7 @@ async function doFetch(event: FetchEvent): Promise<Response> {
if (relativePath.startsWith('/snapshot/')) { if (relativePath.startsWith('/snapshot/')) {
if (!snapshotServer) if (!snapshotServer)
return new Response(null, { status: 404 }); 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/')) { if (relativePath.startsWith('/sha1/')) {
@ -118,7 +119,7 @@ async function doFetch(event: FetchEvent): Promise<Response> {
return fetch(event.request); return fetch(event.request);
} }
const snapshotUrl = client!.url; const snapshotUrl = unwrapPopoutUrl(client!.url);
const traceUrl = new URL(snapshotUrl).searchParams.get('trace')!; const traceUrl = new URL(snapshotUrl).searchParams.get('trace')!;
const { snapshotServer } = loadedTraces.get(traceUrl) || {}; const { snapshotServer } = loadedTraces.get(traceUrl) || {};
if (!snapshotServer) if (!snapshotServer)

View File

@ -63,6 +63,7 @@
flex: auto; flex: auto;
margin: 1px; margin: 1px;
padding: 10px; padding: 10px;
position: relative;
} }
.snapshot-container { .snapshot-container {
@ -82,6 +83,22 @@ iframe#snapshot {
padding: 50px; 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 { .window-dot {
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;

View File

@ -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 }[]; 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,<body style="background: #ddd"></body>'; let snapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
let popoutUrl: string | undefined;
let snapshotInfoUrl: string | undefined; let snapshotInfoUrl: string | undefined;
let pointX: number | undefined; let pointX: number | undefined;
let pointY: number | undefined; let pointY: number | undefined;
@ -49,6 +50,10 @@ export const SnapshotTab: React.FunctionComponent<{
pointX = action.metadata.point?.x; pointX = action.metadata.point?.x;
pointY = action.metadata.point?.y; 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<{
})} })}
</div> </div>
<div ref={ref} className='snapshot-wrapper'> <div ref={ref} className='snapshot-wrapper'>
<a className={`popout-icon ${popoutUrl ? '' : 'popout-disabled'}`} href={popoutUrl} target='_blank' title='Open snapshot in a new tab'>
<span className='codicon codicon-link-external'/>
</a>
{ snapshots.length ? <div className='snapshot-container' style={{ { snapshots.length ? <div className='snapshot-container' style={{
width: snapshotContainerSize.width + 'px', width: snapshotContainerSize.width + 'px',
height: snapshotContainerSize.height + 'px', height: snapshotContainerSize.height + 'px',

View File

@ -38,6 +38,10 @@ export default defineConfig({
// Output dir is shared with vite.sw.config.ts, clearing it here is racy. // Output dir is shared with vite.sw.config.ts, clearing it here is racy.
emptyOutDir: false, emptyOutDir: false,
rollupOptions: { rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
popout: path.resolve(__dirname, 'popout.html'),
},
output: { output: {
entryFileNames: () => '[name].[hash].js', entryFileNames: () => '[name].[hash].js',
assetFileNames: () => '[name].[hash][extname]', assetFileNames: () => '[name].[hash][extname]',

View File

@ -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); 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 }) => { test('should capture iframe with sandbox attribute', async ({ page, server, runAndTrace }) => {
await page.route('**/empty.html', route => { await page.route('**/empty.html', route => {
route.fulfill({ route.fulfill({