From ada68cd6f0cf41b4788a79b8faac44c08cebfe68 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 8 Jan 2025 05:08:00 -0800 Subject: [PATCH] feat(trace-viewer): Add setting for display canvas content in snapshots (#34010) --- .../trace-viewer/src/sw/snapshotRenderer.ts | 21 ++++-- packages/trace-viewer/src/ui/DEPS.list | 1 + .../src/ui/defaultSettingsView.tsx | 45 +++++++++++ .../src/ui/recorder/recorderView.tsx | 12 ++- .../src/ui/settingsToolbarButton.tsx | 52 +++++++++++++ packages/trace-viewer/src/ui/settingsView.css | 1 + .../trace-viewer/src/ui/shared/dialog.tsx | 10 ++- packages/trace-viewer/src/ui/snapshotTab.tsx | 14 +++- packages/trace-viewer/src/ui/uiModeView.tsx | 7 +- .../trace-viewer/src/ui/workbenchLoader.tsx | 5 +- packages/web/src/components/toolbarButton.tsx | 7 +- tests/config/traceViewerFixtures.ts | 12 +++ tests/library/trace-viewer.spec.ts | 75 +++++++++++++++++++ 13 files changed, 234 insertions(+), 28 deletions(-) create mode 100644 packages/trace-viewer/src/ui/defaultSettingsView.tsx create mode 100644 packages/trace-viewer/src/ui/settingsToolbarButton.tsx diff --git a/packages/trace-viewer/src/sw/snapshotRenderer.ts b/packages/trace-viewer/src/sw/snapshotRenderer.ts index 41c23ffa9c..722ef69e40 100644 --- a/packages/trace-viewer/src/sw/snapshotRenderer.ts +++ b/packages/trace-viewer/src/sw/snapshotRenderer.ts @@ -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 = () => { diff --git a/packages/trace-viewer/src/ui/DEPS.list b/packages/trace-viewer/src/ui/DEPS.list index 0056375c05..af02e8bfad 100644 --- a/packages/trace-viewer/src/ui/DEPS.list +++ b/packages/trace-viewer/src/ui/DEPS.list @@ -7,3 +7,4 @@ ../geometry.ts ../../../playwright/src/isomorphic/** ../third_party/devtools.ts +./shared/** \ No newline at end of file diff --git a/packages/trace-viewer/src/ui/defaultSettingsView.tsx b/packages/trace-viewer/src/ui/defaultSettingsView.tsx new file mode 100644 index 0000000000..e445633d19 --- /dev/null +++ b/packages/trace-viewer/src/ui/defaultSettingsView.tsx @@ -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 ( + + ); +}; diff --git a/packages/trace-viewer/src/ui/recorder/recorderView.tsx b/packages/trace-viewer/src/ui/recorder/recorderView.tsx index e9014a6cea..0314ebca1b 100644 --- a/packages/trace-viewer/src/ui/recorder/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorder/recorderView.tsx @@ -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 = () => { { setFileId(fileId); }} /> - toggleTheme()}> + ; const sidebarTabbedPane = ; @@ -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 = () => { + const hostingRef = React.useRef(null); + + const [open, setOpen] = React.useState(false); + + return ( + <> + setOpen(current => !current)} + /> + setOpen(false)} + anchor={hostingRef} + dataTestId='settings-toolbar-dialog' + > + + + + ); +}; diff --git a/packages/trace-viewer/src/ui/settingsView.css b/packages/trace-viewer/src/ui/settingsView.css index f759f0b07a..4142b6897e 100644 --- a/packages/trace-viewer/src/ui/settingsView.css +++ b/packages/trace-viewer/src/ui/settingsView.css @@ -15,6 +15,7 @@ */ .settings-view { + display: flex; flex: none; padding: 4px 0px; row-gap: 8px; diff --git a/packages/trace-viewer/src/ui/shared/dialog.tsx b/packages/trace-viewer/src/ui/shared/dialog.tsx index a58119eca7..23177055af 100644 --- a/packages/trace-viewer/src/ui/shared/dialog.tsx +++ b/packages/trace-viewer/src/ui/shared/dialog.tsx @@ -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; + dataTestId?: string; } export const Dialog: React.FC> = ({ className, + style: externalStyle, open, width, verticalOffset, requestClose, anchor, + dataTestId, children, }) => { const dialogRef = React.useRef(null); @@ -39,17 +43,19 @@ export const Dialog: React.FC> = ({ // 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> = ({ return ( open && ( - + {children} ) diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 995e2d173f..b8c8f1ba04 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -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
@@ -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(); diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 6ae2fdbd8c..4375018765 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -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(null); @@ -521,9 +520,7 @@ export const UIModeView: React.FC<{}> = ({ />
Settings
- {settingsVisible && } + {settingsVisible && }
} /> diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 9f3fe83fc4..9f71eb1142 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -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<{
Playwright
{model.title &&
{model.title}
}
- toggleTheme()}> +
diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 521951bfee..fbdfb738ab 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -31,7 +31,7 @@ export interface ToolbarButtonProps { ariaLabel?: string, } -export const ToolbarButton: React.FC> = ({ +export const ToolbarButton = React.forwardRef>(function ToolbarButton({ children, title = '', icon, @@ -42,8 +42,9 @@ export const ToolbarButton: React.FC testId, className, ariaLabel, -}) => { +}, ref) { return ; -}; +}); export const ToolbarSeparator: React.FC<{ style?: React.CSSProperties }> = ({ style, diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts index 40737f6bc1..e3947ff092 100644 --- a/tests/config/traceViewerFixtures.ts +++ b/tests/config/traceViewerFixtures.ts @@ -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 { await this.selectAction(actionName, ordinal); diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 53c6bcf6c4..59441935cf 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -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,
Hello world
`); + await page.goto(`data:text/html,
Hello world
`); + }); + + 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'); +});