feat(trace-viewer): Add setting for display canvas content in snapshots (#34010)

This commit is contained in:
Adam Gastineau 2025-01-08 05:08:00 -08:00 committed by GitHub
parent ff9242104b
commit ada68cd6f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 234 additions and 28 deletions

View File

@ -255,7 +255,9 @@ declare global {
function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
function applyPlaywrightAttributes(unwrapPopoutUrl: (url: string) => string, viewport: ViewportSize, ...targetIds: (string | undefined)[]) {
const isUnderTest = new URLSearchParams(location.search).has('isUnderTest');
const searchParams = new URLSearchParams(location.search);
const shouldPopulateCanvasFromScreenshot = searchParams.has('shouldPopulateCanvasFromScreenshot');
const isUnderTest = searchParams.has('isUnderTest');
// info to recursively compute canvas position relative to the top snapshot frame.
// Before rendering each iframe, its parent extracts the '__playwright_canvas_render_info__' attribute
@ -512,15 +514,20 @@ function snapshotScript(viewport: ViewportSize, ...targetIds: (string | undefine
drawCheckerboard(context, canvas);
context.drawImage(img, boundingRect.left * img.width, boundingRect.top * img.height, (boundingRect.right - boundingRect.left) * img.width, (boundingRect.bottom - boundingRect.top) * img.height, 0, 0, canvas.width, canvas.height);
if (shouldPopulateCanvasFromScreenshot) {
context.drawImage(img, boundingRect.left * img.width, boundingRect.top * img.height, (boundingRect.right - boundingRect.left) * img.width, (boundingRect.bottom - boundingRect.top) * img.height, 0, 0, canvas.width, canvas.height);
if (partiallyUncaptured)
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.`;
} else {
canvas.title = 'Canvas content display is disabled.';
}
if (isUnderTest)
// eslint-disable-next-line no-console
console.log(`canvas drawn:`, JSON.stringify([boundingRect.left, boundingRect.top, (boundingRect.right - boundingRect.left), (boundingRect.bottom - boundingRect.top)].map(v => Math.floor(v * 100))));
if (partiallyUncaptured)
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 = () => {

View File

@ -7,3 +7,4 @@
../geometry.ts
../../../playwright/src/isomorphic/**
../third_party/devtools.ts
./shared/**

View File

@ -0,0 +1,45 @@
/**
* 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.
*/
import * as React from 'react';
import { SettingsView } from './settingsView';
import { useDarkModeSetting } from '@web/theme';
import { useSetting } from '@web/uiUtils';
/**
* A view of the collection of standard settings used between various applications
*/
export const DefaultSettingsView: React.FC<{}> = () => {
const [
shouldPopulateCanvasFromScreenshot,
setShouldPopulateCanvasFromScreenshot,
] = useSetting('shouldPopulateCanvasFromScreenshot', false);
const [darkMode, setDarkMode] = useDarkModeSetting();
return (
<SettingsView
settings={[
{ value: darkMode, set: setDarkMode, name: 'Dark mode' },
{
value: shouldPopulateCanvasFromScreenshot,
set: setShouldPopulateCanvasFromScreenshot,
name: 'Display canvas content',
title: 'Attempt to display the captured canvas appearance in the snapshot preview. May not be accurate.'
},
]}
/>
);
};

View File

@ -21,7 +21,6 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { TabbedPane } from '@web/components/tabbedPane';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
import { toggleTheme } from '@web/theme';
import { copy, useSetting } from '@web/uiUtils';
import * as React from 'react';
import { ConsoleTab, useConsoleTabModel } from '../consoleTab';
@ -37,6 +36,7 @@ import './recorderView.css';
import { ActionListView } from './actionListView';
import { BackendContext, BackendProvider } from './backendContext';
import type { Language } from '@isomorphic/locatorGenerators';
import { SettingsToolbarButton } from '../settingsToolbarButton';
export const RecorderView: React.FunctionComponent = () => {
const searchParams = new URLSearchParams(window.location.search);
@ -148,7 +148,7 @@ export const Workbench: React.FunctionComponent = () => {
<SourceChooser fileId={fileId} sources={backend?.sources || []} setFileId={fileId => {
setFileId(fileId);
}} />
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
<SettingsToolbarButton />
</Toolbar>;
const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
@ -271,6 +271,10 @@ const TraceView: React.FunctionComponent<{
setHighlightedLocator,
}) => {
const model = React.useContext(ModelContext);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [shouldPopulateCanvasFromScreenshot, _] = useSetting('shouldPopulateCanvasFromScreenshot', false);
const action = React.useMemo(() => {
return model?.actions.find(a => a.callId === callId);
}, [model, callId]);
@ -280,8 +284,8 @@ const TraceView: React.FunctionComponent<{
return snapshot.action || snapshot.after || snapshot.before;
}, [action]);
const snapshotUrls = React.useMemo(() => {
return snapshot ? extendSnapshot(snapshot) : undefined;
}, [snapshot]);
return snapshot ? extendSnapshot(snapshot, shouldPopulateCanvasFromScreenshot) : undefined;
}, [snapshot, shouldPopulateCanvasFromScreenshot]);
return <SnapshotView
sdkLanguage={sdkLanguage}

View File

@ -0,0 +1,52 @@
/*
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.
*/
import * as React from 'react';
import { Dialog } from './shared/dialog';
import { ToolbarButton } from '@web/components/toolbarButton';
import { DefaultSettingsView } from './defaultSettingsView';
export const SettingsToolbarButton: React.FC<{}> = () => {
const hostingRef = React.useRef<HTMLButtonElement>(null);
const [open, setOpen] = React.useState(false);
return (
<>
<ToolbarButton
ref={hostingRef}
icon='settings-gear'
title='Settings'
onClick={() => setOpen(current => !current)}
/>
<Dialog
style={{
backgroundColor: 'var(--vscode-sideBar-background)',
padding: '4px 8px'
}}
open={open}
width={200}
// TODO: Temporary spacing until design of toolbar buttons is revisited
verticalOffset={8}
requestClose={() => setOpen(false)}
anchor={hostingRef}
dataTestId='settings-toolbar-dialog'
>
<DefaultSettingsView />
</Dialog>
</>
);
};

View File

@ -15,6 +15,7 @@
*/
.settings-view {
display: flex;
flex: none;
padding: 4px 0px;
row-gap: 8px;

View File

@ -18,20 +18,24 @@ import * as React from 'react';
export interface DialogProps {
className?: string;
style?: React.CSSProperties;
open: boolean;
width: number;
verticalOffset?: number;
requestClose?: () => void;
anchor?: React.RefObject<HTMLElement>;
dataTestId?: string;
}
export const Dialog: React.FC<React.PropsWithChildren<DialogProps>> = ({
className,
style: externalStyle,
open,
width,
verticalOffset,
requestClose,
anchor,
dataTestId,
children,
}) => {
const dialogRef = React.useRef<HTMLDialogElement>(null);
@ -39,17 +43,19 @@ export const Dialog: React.FC<React.PropsWithChildren<DialogProps>> = ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setRecalculateDimensionsCount] = React.useState(0);
let style: React.CSSProperties | undefined = undefined;
let style: React.CSSProperties | undefined = externalStyle;
if (anchor?.current) {
const bounds = anchor.current.getBoundingClientRect();
style = {
position: 'fixed',
margin: 0,
top: bounds.bottom + (verticalOffset ?? 0),
left: buildTopLeftCoord(bounds, width),
width,
zIndex: 1,
...externalStyle
};
}
@ -92,7 +98,7 @@ export const Dialog: React.FC<React.PropsWithChildren<DialogProps>> = ({
return (
open && (
<dialog ref={dialogRef} style={style} className={className} open>
<dialog ref={dialogRef} style={style} className={className} data-testid={dataTestId} open>
{children}
</dialog>
)

View File

@ -20,7 +20,7 @@ import type { ActionTraceEvent } from '@trace/trace';
import { context, type MultiTraceModel, prevInList } from './modelUtil';
import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton } from '@web/components/toolbarButton';
import { clsx, useMeasure } from '@web/uiUtils';
import { clsx, useMeasure, useSetting } from '@web/uiUtils';
import { InjectedScript } from '@injected/injectedScript';
import { Recorder } from '@injected/recorder/recorder';
import ConsoleAPI from '@injected/consoleApi';
@ -43,13 +43,16 @@ export const SnapshotTabsView: React.FunctionComponent<{
}> = ({ action, sdkLanguage, testIdAttributeName, isInspecting, setIsInspecting, highlightedLocator, setHighlightedLocator }) => {
const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [shouldPopulateCanvasFromScreenshot, _] = useSetting('shouldPopulateCanvasFromScreenshot', false);
const snapshots = React.useMemo(() => {
return collectSnapshots(action);
}, [action]);
const snapshotUrls = React.useMemo(() => {
const snapshot = snapshots[snapshotTab];
return snapshot ? extendSnapshot(snapshot) : undefined;
}, [snapshots, snapshotTab]);
return snapshot ? extendSnapshot(snapshot, shouldPopulateCanvasFromScreenshot) : undefined;
}, [snapshots, snapshotTab, shouldPopulateCanvasFromScreenshot]);
return <div className='snapshot-tab vbox'>
<Toolbar>
@ -327,7 +330,7 @@ export function collectSnapshots(action: ActionTraceEvent | undefined): Snapshot
const isUnderTest = new URLSearchParams(window.location.search).has('isUnderTest');
const serverParam = new URLSearchParams(window.location.search).get('server');
export function extendSnapshot(snapshot: Snapshot): SnapshotUrls {
export function extendSnapshot(snapshot: Snapshot, shouldPopulateCanvasFromScreenshot: boolean): SnapshotUrls {
const params = new URLSearchParams();
params.set('trace', context(snapshot.action).traceUrl);
params.set('name', snapshot.snapshotName);
@ -339,6 +342,9 @@ export function extendSnapshot(snapshot: Snapshot): SnapshotUrls {
if (snapshot.hasInputTarget)
params.set('hasInputTarget', '1');
}
if (shouldPopulateCanvasFromScreenshot)
params.set('shouldPopulateCanvasFromScreenshot', '1');
const snapshotUrl = new URL(`snapshot/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();
const snapshotInfoUrl = new URL(`snapshotInfo/${snapshot.action.pageId}?${params.toString()}`, window.location.href).toString();

View File

@ -28,7 +28,6 @@ import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
import { useDarkModeSetting } from '@web/theme';
import { clsx, settings, useSetting } from '@web/uiUtils';
import { statusEx, TestTree } from '@testIsomorphic/testTree';
import type { TreeItem } from '@testIsomorphic/testTree';
@ -37,6 +36,7 @@ import { FiltersView } from './uiModeFiltersView';
import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView';
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -104,7 +104,6 @@ export const UIModeView: React.FC<{}> = ({
const [singleWorker, setSingleWorker] = React.useState(false);
const [showBrowser, setShowBrowser] = React.useState(false);
const [updateSnapshots, setUpdateSnapshots] = React.useState(false);
const [darkMode, setDarkMode] = useDarkModeSetting();
const inputRef = React.useRef<HTMLInputElement>(null);
@ -521,9 +520,7 @@ export const UIModeView: React.FC<{}> = ({
/>
<div className='section-title'>Settings</div>
</Toolbar>
{settingsVisible && <SettingsView settings={[
{ value: darkMode, set: setDarkMode, name: 'Dark mode' },
]} />}
{settingsVisible && <DefaultSettingsView />}
</div>
}
/>

View File

@ -14,14 +14,13 @@
limitations under the License.
*/
import { ToolbarButton } from '@web/components/toolbarButton';
import * as React from 'react';
import type { ContextEntry } from '../types/entries';
import { MultiTraceModel } from './modelUtil';
import './workbenchLoader.css';
import { toggleTheme } from '@web/theme';
import { Workbench } from './workbench';
import { TestServerConnection, WebSocketTestServerTransport } from '@testIsomorphic/testServerConnection';
import { SettingsToolbarButton } from './settingsToolbarButton';
export const WorkbenchLoader: React.FunctionComponent<{
}> = () => {
@ -161,7 +160,7 @@ export const WorkbenchLoader: React.FunctionComponent<{
<div className='product'>Playwright</div>
{model.title && <div className='title'>{model.title}</div>}
<div className='spacer'></div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
<SettingsToolbarButton />
</div>
<div className='progress'>
<div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div>

View File

@ -31,7 +31,7 @@ export interface ToolbarButtonProps {
ariaLabel?: string,
}
export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>> = ({
export const ToolbarButton = React.forwardRef<HTMLButtonElement, React.PropsWithChildren<ToolbarButtonProps>>(function ToolbarButton({
children,
title = '',
icon,
@ -42,8 +42,9 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
testId,
className,
ariaLabel,
}) => {
}, ref) {
return <button
ref={ref}
className={clsx(className, 'toolbar-button', icon, toggled && 'toggled')}
onMouseDown={preventDefault}
onClick={onClick}
@ -57,7 +58,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
{children}
</button>;
};
});
export const ToolbarSeparator: React.FC<{ style?: React.CSSProperties }> = ({
style,

View File

@ -49,6 +49,10 @@ class TraceViewerPage {
snapshotContainer: Locator;
sourceCodeTab: Locator;
settingsDialog: Locator;
darkModeSetting: Locator;
displayCanvasContentSetting: Locator;
constructor(public page: Page) {
this.actionTitles = page.locator('.action-title');
this.actionsTree = page.getByTestId('actions-tree');
@ -63,6 +67,10 @@ class TraceViewerPage {
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
this.metadataTab = page.getByTestId('metadata-view');
this.sourceCodeTab = page.getByTestId('source-code');
this.settingsDialog = page.getByTestId('settings-toolbar-dialog');
this.darkModeSetting = page.locator('.setting').getByText('Dark mode');
this.displayCanvasContentSetting = page.locator('.setting').getByText('Display canvas content');
}
async actionIconsText(action: string) {
@ -115,6 +123,10 @@ class TraceViewerPage {
await this.page.click('text="Metadata"');
}
async showSettings() {
await this.page.locator('.settings-gear').click();
}
@step
async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise<FrameLocator> {
await this.selectAction(actionName, ordinal);

View File

@ -1521,12 +1521,26 @@ test('should serve css without content-type', async ({ page, runAndTrace, server
await expect(snapshotFrame.locator('body')).toHaveCSS('background-color', 'rgb(255, 0, 0)', { timeout: 0 });
});
test('canvas disabled title', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.PREFIX + '/screenshots/canvas.html#canvas-on-edge');
await rafraf(page, 5);
});
const snapshot = await traceViewer.snapshotFrame('page.goto');
await expect(snapshot.locator('canvas')).toHaveAttribute('title', `Canvas content display is disabled.`);
});
test('canvas clipping', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.PREFIX + '/screenshots/canvas.html#canvas-on-edge');
await rafraf(page, 5);
});
// Enable canvas display
await traceViewer.showSettings();
await traceViewer.displayCanvasContentSetting.click();
const msg = await traceViewer.page.waitForEvent('console', { predicate: msg => msg.text().startsWith('canvas drawn:') });
expect(msg.text()).toEqual('canvas drawn: [0,91,11,20]');
@ -1543,6 +1557,10 @@ test('canvas clipping in iframe', async ({ runAndTrace, page, server }) => {
await rafraf(page, 5);
});
// Enable canvas display
await traceViewer.showSettings();
await traceViewer.displayCanvasContentSetting.click();
const msg = await traceViewer.page.waitForEvent('console', { predicate: msg => msg.text().startsWith('canvas drawn:') });
expect(msg.text()).toEqual('canvas drawn: [1,1,11,20]');
@ -1593,3 +1611,60 @@ test('should show a popover', async ({ runAndTrace, page, server }) => {
const popover = snapshot.locator('#pop');
await expect.poll(() => popover.evaluate(e => e.matches(':popover-open'))).toBe(true);
});
test('should open settings dialog', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('http://localhost');
await traceViewer.showSettings();
await expect(traceViewer.settingsDialog).toBeVisible();
});
test('should toggle theme color', async ({ showTraceViewer, page }) => {
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('http://localhost');
await traceViewer.showSettings();
await expect(traceViewer.darkModeSetting).toBeChecked({ checked: false });
await traceViewer.darkModeSetting.click();
await expect(traceViewer.darkModeSetting).toBeChecked({ checked: true });
await expect(traceViewer.page.locator('.dark-mode')).toBeVisible();
await traceViewer.darkModeSetting.click();
await expect(traceViewer.darkModeSetting).toBeChecked({ checked: false });
await expect(traceViewer.page.locator('.light-mode')).toBeVisible();
});
test('should toggle canvas rendering', async ({ runAndTrace, page }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(`data:text/html,<!DOCTYPE html><body><div>Hello world</div><canvas /></body>`);
await page.goto(`data:text/html,<!DOCTYPE html><body><div>Hello world</div></body>`);
});
let snapshotRequestPromise = traceViewer.page.waitForRequest(request => request.url().includes('/snapshot/'));
// Click on the action with a canvas snapshot
await traceViewer.selectAction('goto', 0);
let snapshotRequest = await snapshotRequestPromise;
expect(snapshotRequest.url()).not.toContain('shouldPopulateCanvasFromScreenshot');
await traceViewer.showSettings();
await expect(traceViewer.displayCanvasContentSetting).toBeChecked({ checked: false });
await traceViewer.displayCanvasContentSetting.click();
await expect(traceViewer.displayCanvasContentSetting).toBeChecked({ checked: true });
// Deselect canvas
await traceViewer.selectAction('goto', 1);
snapshotRequestPromise = traceViewer.page.waitForRequest(request => request.url().includes('/snapshot/'));
// Select canvas again
await traceViewer.selectAction('goto', 0);
snapshotRequest = await snapshotRequestPromise;
expect(snapshotRequest.url()).toContain('shouldPopulateCanvasFromScreenshot');
});