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
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega

onFrameSnapshot(snapshot: FrameSnapshot): void {
++this._snapshotCount;
const renderer = this._storage.addFrameSnapshot(snapshot);
const renderer = this._storage.addFrameSnapshot(snapshot, []);
this._snapshotReadyPromises.get(snapshot.snapshotName || '')?.resolve(renderer);
}

Expand Down
7 changes: 7 additions & 0 deletions packages/trace-viewer/src/sw/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ async function doFetch(event: FetchEvent): Promise<Response> {
return response;
}

if (relativePath.startsWith('/closest-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
94 changes: 93 additions & 1 deletion packages/trace-viewer/src/sw/snapshotRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@

import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils';
import type { FrameSnapshot, NodeNameAttributesChildNodesSnapshot, NodeSnapshot, RenderedFrameSnapshot, ResourceSnapshot, SubtreeReferenceSnapshot } from '@trace/snapshot';
import type { 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);
});
}

function isNodeNameAttributesChildNodesSnapshot(n: NodeSnapshot): n is NodeNameAttributesChildNodesSnapshot {
return Array.isArray(n) && typeof n[0] === 'string';
Expand Down Expand Up @@ -60,13 +70,15 @@ export class SnapshotRenderer {
private _resources: ResourceSnapshot[];
private _snapshot: FrameSnapshot;
private _callId: string;
private _screencastFrames: PageEntry['screencastFrames'];

constructor(resources: ResourceSnapshot[], snapshots: FrameSnapshot[], index: number) {
constructor(resources: ResourceSnapshot[], snapshots: FrameSnapshot[], screencastFrames: PageEntry['screencastFrames'], index: number) {
this._resources = resources;
this._snapshots = snapshots;
this._index = index;
this._snapshot = snapshots[index];
this._callId = snapshots[index].callId;
this._screencastFrames = screencastFrames;
this.snapshotName = snapshots[index].snapshotName;
}

Expand All @@ -78,6 +90,14 @@ export class SnapshotRenderer {
return this._snapshots[this._index].viewport;
}

closestScreenshot(): string | undefined {
const { wallTime, timestamp } = this.snapshot();
const closestFrame = (wallTime && this._screencastFrames[0]?.frameSwapWallTime)
? findClosest(this._screencastFrames, frame => frame.frameSwapWallTime!, wallTime)
: findClosest(this._screencastFrames, frame => frame.timestamp, timestamp);
return closestFrame?.sha1;
}

render(): RenderedFrameSnapshot {
const result: string[] = [];
const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined, parentAttrs: [string, string][] | undefined) => {
Expand Down Expand Up @@ -244,13 +264,16 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {

function snapshotScript(...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, ...targetIds: (string | undefined)[]) {
const isUnderTest = new URLSearchParams(location.search).has('isUnderTest');

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.';

const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
const targetElements: Element[] = [];
const canvasElements: HTMLCanvasElement[] = [];

const visit = (root: Document | ShadowRoot) => {
// Collect all scrolled elements for later use.
Expand Down Expand Up @@ -326,6 +349,8 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
}
(root as any).adoptedStyleSheets = adoptedSheets;
}

canvasElements.push(...root.querySelectorAll('canvas'));
};

const onLoad = () => {
Expand Down Expand Up @@ -393,6 +418,73 @@ function snapshotScript(...targetIds: (string | undefined)[]) {
}
}
}

if (canvasElements.length > 0) {
function drawWarningBackground(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) {
Skn0tt marked this conversation as resolved.
Show resolved Hide resolved
function createCheckerboardPattern() {
const pattern = document.createElement('canvas');
pattern.width = pattern.width / Math.floor(pattern.width / 24);
pattern.height = pattern.height / Math.floor(pattern.height / 24);
const context = pattern.getContext('2d')!;
context.fillStyle = 'lightgray';
context.fillRect(0, 0, pattern.width, pattern.height);
context.fillStyle = 'white';
context.fillRect(0, 0, pattern.width / 2, pattern.height / 2);
context.fillRect(pattern.width / 2, pattern.height / 2, pattern.width, pattern.height);
return context.createPattern(pattern, 'repeat')!;
}

context.fillStyle = createCheckerboardPattern();
context.fillRect(0, 0, canvas.width, canvas.height);
}


if (window.parent.parent !== window.parent) {
Skn0tt marked this conversation as resolved.
Show resolved Hide resolved
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
drawWarningBackground(context, canvas);
canvas.title = `Playwright displays canvas contents on a best-effort basis. It doesn't support canvas elements inside an iframe yet. If this impacts your workflow, please open an issue so we can prioritize.`;
}
return;
}

const img = new Image();
img.onload = () => {
for (const canvas of canvasElements) {
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;

drawWarningBackground(context, canvas);

context.drawImage(img, xStart * img.width, yStart * img.height, (xEnd - xStart) * img.width, (yEnd - yStart) * img.height, 0, 0, canvas.width, canvas.height);
if (isUnderTest)
// eslint-disable-next-line no-console
console.log(`canvas drawn:`, JSON.stringify([xStart, yStart, xEnd, yEnd].map(v => Math.floor(v * 100))));

if (xEnd > 1 || yEnd > 1) {
if (xStart > 1 || yStart > 1)
canvas.title = `Playwright couldn't capture canvas contents because it's located outside the viewport.`;
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's skip both drawWarningBackground and drawImage calls in this case.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've made it skip drawImage, but I don't think we should skip drawCheckerboard - having that around is still valuable to show that there's something missing.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've also made it skip drawCheckerboard to align with what we discussed in the meeting. Gonna potentially change that in a follow-up. ea4116e

else
canvas.title = `Playwright couldn't capture full canvas contents because it's located partially outside the viewport.`;
} else {
canvas.title = `Canvas contents are displayed on a best-effort basis based on viewport screenshots taken during test execution.`;
}
}
};
img.onerror = () => {
for (const canvas of canvasElements) {
const context = canvas.getContext('2d')!;
drawWarningBackground(context, canvas);
canvas.title = `Playwright couldn't show canvas contents because the screenshot failed to load.`;
}
};
img.src = location.href.replace('/snapshot', '/closest-screenshot');
}
};

const onDOMContentLoaded = () => visit(document);
Expand Down
9 changes: 9 additions & 0 deletions packages/trace-viewer/src/sw/snapshotServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,20 @@ export class SnapshotServer {
const snapshot = this._snapshot(pathname.substring('/snapshot'.length), searchParams);
if (!snapshot)
return new Response(null, { status: 404 });

const renderedSnapshot = snapshot.render();
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 snapshot = this._snapshot(pathname.substring('/closest-screenshot'.length), searchParams);
const sha1 = snapshot?.closestScreenshot();
if (!sha1)
return new Response(null, { status: 404 });
return new Response(await this._resourceLoader(sha1));
}

serveSnapshotInfo(pathname: string, searchParams: URLSearchParams): Response {
const snapshot = this._snapshot(pathname.substring('/snapshotInfo'.length), searchParams);
return this._respondWithJson(snapshot ? {
Expand Down
5 changes: 3 additions & 2 deletions packages/trace-viewer/src/sw/snapshotStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import type { FrameSnapshot, ResourceSnapshot } from '@trace/snapshot';
import { rewriteURLForCustomProtocol, SnapshotRenderer } from './snapshotRenderer';
import type { PageEntry } from '../types/entries';

export class SnapshotStorage {
private _resources: ResourceSnapshot[] = [];
Expand All @@ -29,7 +30,7 @@ export class SnapshotStorage {
this._resources.push(resource);
}

addFrameSnapshot(snapshot: FrameSnapshot) {
addFrameSnapshot(snapshot: FrameSnapshot, screencastFrames: PageEntry['screencastFrames']) {
for (const override of snapshot.resourceOverrides)
override.url = rewriteURLForCustomProtocol(override.url);
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
Expand All @@ -43,7 +44,7 @@ export class SnapshotStorage {
this._frameSnapshots.set(snapshot.pageId, frameSnapshots);
}
frameSnapshots.raw.push(snapshot);
const renderer = new SnapshotRenderer(this._resources, frameSnapshots.raw, frameSnapshots.raw.length - 1);
const renderer = new SnapshotRenderer(this._resources, frameSnapshots.raw, screencastFrames, frameSnapshots.raw.length - 1);
frameSnapshots.renderers.push(renderer);
return renderer;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/trace-viewer/src/sw/traceModernizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export class TraceModernizer {
contextEntry.resources.push(event.snapshot);
break;
case 'frame-snapshot':
this._snapshotStorage.addFrameSnapshot(event.snapshot);
this._snapshotStorage.addFrameSnapshot(event.snapshot, this._pageEntry(event.snapshot.pageId).screencastFrames);
break;
}
// Make sure there is a page entry for each page, even without screencast frames,
Expand Down
4 changes: 4 additions & 0 deletions packages/trace-viewer/src/ui/snapshotTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,14 @@ export function collectSnapshots(action: ActionTraceEvent | undefined): Snapshot
return { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot };
}

const isUnderTest = new URLSearchParams(window.location.search).has('isUnderTest');

export function extendSnapshot(snapshot: Snapshot): SnapshotUrls {
const params = new URLSearchParams();
params.set('trace', context(snapshot.action).traceUrl);
params.set('name', snapshot.snapshotName);
if (isUnderTest)
params.set('isUnderTest', 'true');
if (snapshot.point) {
params.set('pointX', String(snapshot.point.x));
params.set('pointY', String(snapshot.point.y));
Expand Down
3 changes: 3 additions & 0 deletions tests/assets/screenshots/canvas.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@
ctx.fillRect(25, 25, 100, 100);
ctx.clearRect(45, 45, 60, 60);
ctx.strokeRect(50, 50, 50, 50);

if (location.hash.includes('canvas-on-edge'))
canvas.style.marginTop = '90vh';
</script>
26 changes: 26 additions & 0 deletions tests/library/trace-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1439,6 +1439,32 @@ 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#canvas-on-edge');
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.

});

const msg = await traceViewer.page.waitForEvent('console', { predicate: msg => msg.text().startsWith('canvas drawn:') });
expect(msg.text()).toEqual('canvas drawn: [0,91,12,111]');

const snapshot = await traceViewer.snapshotFrame('page.goto');
await expect(snapshot.locator('canvas')).toHaveAttribute('title', `Playwright couldn't capture full canvas contents because it's located partially outside the viewport.`);
});

test('canvas clipping in iframe', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.setContent(`
<iframe src="${server.PREFIX}/screenshots/canvas.html#canvas-on-edge"></iframe>
`);
await page.waitForTimeout(1000); // ensure we could take a screenshot
});

const snapshot = await traceViewer.snapshotFrame('page.waitForTimeout');
const canvas = snapshot.locator('iframe').contentFrame().locator('canvas');
await expect(canvas).toHaveAttribute('title', `Playwright displays canvas contents on a best-effort basis. It doesn't support canvas elements inside an iframe yet. If this impacts your workflow, please open an issue so we can prioritize.`);
});

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