diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 99f7e8cb0f..b1d8003374 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -104,7 +104,7 @@ export class SnapshotRenderer { const prefix = snapshot.doctype ? `` : ''; html = prefix + [ '', - ``, + ``, `` ].join('') + html; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 28f25eb922..521095e364 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -22,7 +22,8 @@ import type { ContextEntry, PageEntry } from '../entries'; import type { SerializedError, StackFrame } from '@protocol/channels'; const contextSymbol = Symbol('context'); -const nextSymbol = Symbol('next'); +const nextInContextSymbol = Symbol('next'); +const prevInListSymbol = Symbol('prev'); const eventsSymbol = Symbol('events'); const resourcesSymbol = Symbol('resources'); @@ -78,7 +79,7 @@ function indexModel(context: ContextEntry) { for (let i = 0; i < context.actions.length; ++i) { const action = context.actions[i] as any; action[contextSymbol] = context; - action[nextSymbol] = context.actions[i + 1]; + action[nextInContextSymbol] = context.actions[i + 1]; } for (const event of context.events) (event as any)[contextSymbol] = context; @@ -114,15 +115,22 @@ function dedupeActions(actions: ActionTraceEvent[]) { result.push(expectAction); } - return result.sort((a1, a2) => a1.startTime - a2.startTime); + result.sort((a1, a2) => a1.startTime - a2.startTime); + for (let i = 1; i < result.length; ++i) + (result[i] as any)[prevInListSymbol] = result[i - 1]; + return result; } export function context(action: ActionTraceEvent): ContextEntry { return (action as any)[contextSymbol]; } -function next(action: ActionTraceEvent): ActionTraceEvent { - return (action as any)[nextSymbol]; +function nextInContext(action: ActionTraceEvent): ActionTraceEvent { + return (action as any)[nextInContextSymbol]; +} + +export function prevInList(action: ActionTraceEvent): ActionTraceEvent { + return (action as any)[prevInListSymbol]; } export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { @@ -149,7 +157,7 @@ export function eventsForAction(action: ActionTraceEvent): EventTraceEvent[] { if (result) return result; - const nextAction = next(action); + const nextAction = nextInContext(action); result = context(action).events.filter(event => { return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime); }); @@ -162,7 +170,7 @@ export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[] if (result) return result; - const nextAction = next(action); + const nextAction = nextInContext(action); result = context(action).resources.filter(resource => { return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.startTime && (!nextAction || resource._monotonicTime < nextAction.startTime); }); diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index d934a2e192..7a4cac8c7e 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -18,7 +18,7 @@ import './snapshotTab.css'; import * as React from 'react'; import { useMeasure } from './helpers'; import type { ActionTraceEvent } from '@trace/trace'; -import { context } from './modelUtil'; +import { context, prevInList } from './modelUtil'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { Toolbar } from '@web/components/toolbar'; import { ToolbarButton } from '@web/components/toolbarButton'; @@ -36,49 +36,46 @@ export const SnapshotTab: React.FunctionComponent<{ testIdAttributeName: string, }> = ({ action, sdkLanguage, testIdAttributeName }) => { const [measure, ref] = useMeasure(); - const [snapshotIndex, setSnapshotIndex] = React.useState(0); + const [snapshotTab, setSnapshotTab] = React.useState<'action'|'before'|'after'>('action'); const [isInspecting, setIsInspecting] = React.useState(false); const [highlightedLocator, setHighlightedLocator] = React.useState(''); const [pickerVisible, setPickerVisible] = React.useState(false); - const { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => { - const actionSnapshot = action?.inputSnapshot || action?.afterSnapshot; - const snapshots = [ - actionSnapshot ? { title: 'action', snapshotName: actionSnapshot } : undefined, - action?.beforeSnapshot ? { title: 'before', snapshotName: action?.beforeSnapshot } : undefined, - action?.afterSnapshot ? { title: 'after', snapshotName: action.afterSnapshot } : undefined, - ].filter(Boolean) as { title: string, snapshotName: string }[]; + const { snapshots } = React.useMemo(() => { + if (!action) + return { snapshots: {} }; - let snapshotUrl = 'data:text/html,'; - let popoutUrl: string | undefined; - let snapshotInfoUrl: string | undefined; - let pointX: number | undefined; - let pointY: number | undefined; - if (action) { - const snapshot = snapshots[snapshotIndex]; - if (snapshot && snapshot.snapshotName) { - const params = new URLSearchParams(); - params.set('trace', context(action).traceUrl); - params.set('name', snapshot.snapshotName); - snapshotUrl = new URL(`snapshot/${action.pageId}?${params.toString()}`, window.location.href).toString(); - snapshotInfoUrl = new URL(`snapshotInfo/${action.pageId}?${params.toString()}`, window.location.href).toString(); - if (snapshot.title === 'action') { - pointX = action.point?.x; - pointY = action.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(); - } + // if the action has no beforeSnapshot, use the last available afterSnapshot. + let beforeSnapshot = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined; + let a = action; + while (!beforeSnapshot && a) { + a = prevInList(a); + beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined; } - return { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl }; - }, [action, snapshotIndex]); + const afterSnapshot = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot; + const actionSnapshot = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot } : afterSnapshot; + return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } }; + }, [action]); - React.useEffect(() => { - if (snapshots.length >= 1 && snapshotIndex >= snapshots.length) - setSnapshotIndex(snapshots.length - 1); - }, [snapshotIndex, snapshots]); + const { snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => { + const snapshot = snapshots[snapshotTab]; + if (!snapshot) + return { snapshotUrl: kBlankSnapshotUrl }; + + const params = new URLSearchParams(); + params.set('trace', context(snapshot.action).traceUrl); + params.set('name', snapshot.snapshotName); + 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(); + + const pointX = snapshotTab === 'action' ? snapshot.action.point?.x : undefined; + const pointY = snapshotTab === 'action' ? snapshot.action.point?.y : undefined; + const popoutParams = new URLSearchParams(); + popoutParams.set('r', snapshotUrl); + popoutParams.set('trace', context(snapshot.action).traceUrl); + const popoutUrl = new URL(`popout.html?${popoutParams.toString()}`, window.location.href).toString(); + return { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl }; + }, [snapshots, snapshotTab]); const iframeRef = React.useRef(null); const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' }); @@ -141,12 +138,12 @@ export const SnapshotTab: React.FunctionComponent<{ setIsInspecting(!pickerVisible); }}>Pick locator
- {snapshots.map((snapshot, index) => { + {['action', 'before', 'after'].map(tab => { return setSnapshotIndex(index)} + id={tab} + title={renderTitle(tab)} + selected={snapshotTab === tab} + onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')} >; })}
@@ -168,7 +165,7 @@ export const SnapshotTab: React.FunctionComponent<{ }}> }
- { snapshots.length ?
-
:
Action does not have snapshots
- } + ; }; @@ -215,8 +211,13 @@ export const InspectModeController: React.FunctionComponent<{ }> = ({ iframe, isInspecting, sdkLanguage, testIdAttributeName, highlightedLocator, setHighlightedLocator }) => { React.useEffect(() => { const win = iframe?.contentWindow as any; - if (!win || !isInspecting && !highlightedLocator && !win._recorder) + try { + if (!win || !isInspecting && !highlightedLocator && !win._recorder) + return; + } catch { + // Potential cross-origin exception. return; + } let recorder: Recorder | undefined = win._recorder; if (!recorder) { const injectedScript = new InjectedScript(win, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); @@ -240,3 +241,4 @@ export const InspectModeController: React.FunctionComponent<{ }; const kDefaultViewport = { width: 1280, height: 720 }; +const kBlankSnapshotUrl = 'data:text/html,'; diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts new file mode 100644 index 0000000000..46947328ce --- /dev/null +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -0,0 +1,95 @@ +/** + * 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 { test, expect } from './ui-mode-fixtures'; +test.describe.configure({ mode: 'parallel' }); + +test('should merge trace events', async ({ runUITest, server }) => { + const page = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('trace test', async ({ page }) => { + await page.setContent(''); + expect(1).toBe(1); + await page.getByRole('button').click(); + expect(2).toBe(2); + }); + `, + }); + + await page.getByText('trace test').dblclick(); + + const listItem = page.getByTestId('action-list').getByRole('listitem'); + await expect( + listItem, + 'action list' + ).toHaveText([ + /browserContext\.newPage[\d.]+m?s/, + /page\.setContent[\d.]+m?s/, + /expect\.toBe[\d.]+m?s/, + /locator\.clickgetByRole\('button'\)[\d.]+m?s/, + /expect\.toBe[\d.]+m?s/, + ]); +}); + +test('should locate sync assertions in source', async ({ runUITest, server }) => { + const page = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('trace test', async ({}) => { + expect(1).toBe(1); + }); + `, + }); + + await page.getByText('trace test').dblclick(); + + await expect( + page.locator('.CodeMirror .source-line-running'), + 'check source tab', + ).toHaveText('4 expect(1).toBe(1);'); +}); + +test('should show snapshots for sync assertions', async ({ runUITest, server }) => { + const page = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('trace test', async ({ page }) => { + await page.setContent(''); + await page.getByRole('button').click(); + expect(1).toBe(1); + }); + `, + }); + + await page.getByText('trace test').dblclick(); + + const listItem = page.getByTestId('action-list').getByRole('listitem'); + await expect( + listItem, + 'action list' + ).toHaveText([ + /browserContext\.newPage[\d.]+m?s/, + /page\.setContent[\d.]+m?s/, + /locator\.clickgetByRole\('button'\)[\d.]+m?s/, + /expect\.toBe[\d.]+m?s/, + ]); + + await expect( + page.frameLocator('id=snapshot').locator('button'), + 'verify snapshot' + ).toHaveText('Submit'); +});