Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tracing): clip canvas contents from screenshots #33119

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
11 changes: 9 additions & 2 deletions packages/trace-viewer/src/sw/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
throw new Error(`Could not load trace from ${traceFileName}. Make sure to upload a valid Playwright trace.`);
throw new Error(`Could not load trace from ${traceUrl}. Make sure a valid Playwright Trace is accessible over this url.`);
}
const snapshotServer = new SnapshotServer(traceModel.storage(), sha1 => traceModel.resourceForSha1(sha1));
const snapshotServer = new SnapshotServer(traceModel.storage(), sha1 => traceModel.resourceForSha1(sha1), traceModel.contextEntries);
loadedTraces.set(traceUrl, { traceModel, snapshotServer });
return traceModel;
}
Expand Down Expand Up @@ -123,12 +123,19 @@ async function doFetch(event: FetchEvent): Promise<Response> {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer)
return new Response(null, { status: 404 });
const response = snapshotServer.serveSnapshot(relativePath, url.searchParams, url.href);
const response = snapshotServer.serveSnapshot(relativePath, url.searchParams, url.href, self.registration.scope, traceUrl!);
if (isDeployedAsHttps)
response.headers.set('Content-Security-Policy', 'upgrade-insecure-requests');
return response;
}

if (relativePath.startsWith('/screenshot/')) {
const { snapshotServer } = loadedTraces.get(traceUrl!) || {};
if (!snapshotServer)
return new Response(null, { status: 404 });
return snapshotServer.serveClosestScreenshot(relativePath, url.searchParams);
}

if (relativePath.startsWith('/sha1/')) {
// Sha1 for sources is based on the file path, can't load it of a random model.
const sha1 = relativePath.slice('/sha1/'.length);
Expand Down
33 changes: 28 additions & 5 deletions packages/trace-viewer/src/sw/snapshotRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class SnapshotRenderer {
return this._snapshots[this._index].viewport;
}

render(): RenderedFrameSnapshot {
render(swScope: string, traceURL: string): RenderedFrameSnapshot {
const result: string[] = [];
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => {
// Text node.
Expand Down Expand Up @@ -154,12 +154,16 @@ export class SnapshotRenderer {
const html = lruCache(this, () => {
visit(snapshot.html, this._index, undefined, undefined);

const screenshotURL = new URL(`./screenshot/${snapshot.pageId}`, swScope);
screenshotURL.searchParams.set('trace', traceURL);
screenshotURL.searchParams.set('name', this.snapshotName!);

const html = result.join('');
// Hide the document in order to prevent flickering. We will unhide once script has processed shadow.
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
return prefix + [
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<script>${snapshotScript(this._callId, this.snapshotName)}</script>`
`<script>${snapshotScript(screenshotURL.toString(), this._callId, this.snapshotName)}</script>`
].join('') + html;
});

Expand Down Expand Up @@ -242,8 +246,8 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
return (snapshot as any)._nodes;
}

function snapshotScript(...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, ...targetIds: (string | undefined)[]) {
function snapshotScript(screenshotURL: string | undefined, ...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, screenshotURL: string | undefined, ...targetIds: (string | undefined)[]) {
const kPointerWarningTitle = 'Recorded click position in absolute coordinates did not' +
' match the center of the clicked element. This is likely due to a difference between' +
' the test runner and the trace viewer operating systems.';
Expand Down Expand Up @@ -299,6 +303,25 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
}
}

const canvases = root.querySelectorAll('canvas');
if (canvases.length > 0 && screenshotURL) {
const img = new Image();
img.onload = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please handle img.onerror as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

for (const canvas of canvases) {
const context = canvas.getContext('2d')!;

const boundingRect = canvas.getBoundingClientRect();
const xStart = boundingRect.left / window.innerWidth;
const yStart = boundingRect.top / window.innerHeight;
const xEnd = boundingRect.right / window.innerWidth;
const yEnd = boundingRect.bottom / window.innerHeight;

context.drawImage(img, xStart * img.width, yStart * img.height, (xEnd - xStart) * img.width, (yEnd - yStart) * img.height, 0, 0, canvas.width, canvas.height);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Is this going to work nicely for the out-of-viewport canvas? Perhaps we should say "not captured" there instead?
  • This code must run after scrollLeft/scrollTop are restored in onLoad().
  • We should still have a warning somewhere that would explain that image might be wrong.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

implemented all of it

}
};
img.src = screenshotURL;
}

{
const body = root.querySelector(`body[__playwright_custom_elements__]`);
if (body && window.customElements) {
Expand Down Expand Up @@ -401,7 +424,7 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
window.addEventListener('DOMContentLoaded', onDOMContentLoaded);
}

return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()}${targetIds.map(id => `, "${id}"`).join('')})`;
return `\n(${applyPlaywrightAttributes.toString()})(${unwrapPopoutUrl.toString()}, ${JSON.stringify(screenshotURL)}${targetIds.map(id => `, "${id}"`).join('')})`;
}


Expand Down
43 changes: 40 additions & 3 deletions packages/trace-viewer/src/sw/snapshotServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,65 @@ import type { URLSearchParams } from 'url';
import type { SnapshotRenderer } from './snapshotRenderer';
import type { SnapshotStorage } from './snapshotStorage';
import type { ResourceSnapshot } from '@trace/snapshot';
import type { ContextEntry, PageEntry } from '../types/entries';

function findClosest<T>(items: T[], metric: (v: T) => number, target: number) {
return items.find((item, index) => {
if (index === items.length - 1)
return true;
const next = items[index + 1];
return Math.abs(metric(item) - target) < Math.abs(metric(next) - target);
});
}

type Point = { x: number, y: number };

export class SnapshotServer {
private _snapshotStorage: SnapshotStorage;
private _resourceLoader: (sha1: string) => Promise<Blob | undefined>;
private _snapshotIds = new Map<string, SnapshotRenderer>();
private _pages: Map<string, PageEntry>;

constructor(snapshotStorage: SnapshotStorage, resourceLoader: (sha1: string) => Promise<Blob | undefined>) {
constructor(snapshotStorage: SnapshotStorage, resourceLoader: (sha1: string) => Promise<Blob | undefined>, contextEntries: ContextEntry[]) {
this._snapshotStorage = snapshotStorage;
this._resourceLoader = resourceLoader;
this._pages = new Map(contextEntries.flatMap(c => c.pages.map(p => [p.pageId, p])));
}

serveSnapshot(pathname: string, searchParams: URLSearchParams, snapshotUrl: string): Response {
serveSnapshot(pathname: string, searchParams: URLSearchParams, snapshotUrl: string, swScope: string, traceUrl: string): Response {
const snapshot = this._snapshot(pathname.substring('/snapshot'.length), searchParams);
if (!snapshot)
return new Response(null, { status: 404 });
const renderedSnapshot = snapshot.render();
const renderedSnapshot = snapshot.render(swScope, traceUrl);
this._snapshotIds.set(snapshotUrl, snapshot);
return new Response(renderedSnapshot.html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}

async serveClosestScreenshot(pathname: string, searchParams: URLSearchParams): Promise<Response> {
const snapshotRenderer = this._snapshot(pathname.substring('/screenshot'.length), searchParams);
if (!snapshotRenderer)
return new Response(undefined, { status: 404 });

const { wallTime, timestamp, pageId } = snapshotRenderer.snapshot();
const page = this._pages.get(pageId);
if (!page)
return new Response(undefined, { status: 404 });

let sha1 = undefined;
if (wallTime && page.screencastFrames[0]?.frameSwapWallTime)
sha1 = findClosest(page.screencastFrames, frame => frame.frameSwapWallTime!, wallTime)?.sha1;
sha1 ??= findClosest(page.screencastFrames, frame => frame.timestamp, timestamp)?.sha1;

if (!sha1)
return new Response(undefined, { status: 404 });

const blob = await this._resourceLoader(sha1);
if (!blob)
return new Response(undefined, { status: 404 });

return new Response(blob);
}

serveSnapshotInfo(pathname: string, searchParams: URLSearchParams): Response {
const snapshot = this._snapshot(pathname.substring('/snapshotInfo'.length), searchParams);
return this._respondWithJson(snapshot ? {
Expand Down
9 changes: 9 additions & 0 deletions tests/library/trace-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,15 @@ test.skip('should allow showing screenshots instead of snapshots', async ({ runA
await expect(screenshot).toBeVisible();
});

test('canvas clipping', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.PREFIX + '/screenshots/canvas.html');
await page.waitForTimeout(1000); // ensure we could take a screenshot
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I predict this will be flaky 😄 We usually do a few rafs in the hopes it will redraw, but even that does not always help.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's a raf?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

});

await traceViewer.page.pause();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test isn't gonna stay, obviously. but you can use it to play around with the feature: npm run ctest -- --grep 'clipping' --headed

@dgozman what's the best way of testing this - should we trace a page with a canvas and then take a screenshot of the trace, to ensure the screenshot isn't blank? Or somehow compute if it's blank without storing a golden image?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that would be a flaky test, unfortunately. Perhaps just check some test-specific logging, like we do in recorder tests:

if (isUnderTest) console.error(`drawn 15;23-184;566 screenshot rect on the canvas`);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, good idea! implemented

});

test.skip('should handle case where neither snapshots nor screenshots exist', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.PREFIX + '/one-style.html');
Expand Down
Loading