chore: show snapshots for sync assertions (#21775)

This commit is contained in:
Pavel Feldman 2023-03-17 20:20:35 -07:00 committed by GitHub
parent 32d33cb8d5
commit 22e11a12ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 159 additions and 54 deletions

View File

@ -104,7 +104,7 @@ export class SnapshotRenderer {
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
html = prefix + [
'<style>*,*::before,*::after { visibility: hidden }</style>',
`<style>*[__playwright_target__="${this._callId}"] { background-color: #6fa8dc7f; }</style>`,
`<style>*[__playwright_target__="${this._callId}"] { outline: 2px solid #006ab1 !important; background-color: #6fa8dc7f !important; }</style>`,
`<script>${snapshotScript()}</script>`
].join('') + html;

View File

@ -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);
});

View File

@ -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<HTMLDivElement>();
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<string>('');
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,<body style="background: #ddd"></body>';
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<HTMLIFrameElement>(null);
const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' });
@ -141,12 +138,12 @@ export const SnapshotTab: React.FunctionComponent<{
setIsInspecting(!pickerVisible);
}}>Pick locator</ToolbarButton>
<div style={{ width: 5 }}></div>
{snapshots.map((snapshot, index) => {
{['action', 'before', 'after'].map(tab => {
return <TabbedPaneTab
id={snapshot.title}
title={renderTitle(snapshot.title)}
selected={snapshotIndex === index}
onSelect={() => setSnapshotIndex(index)}
id={tab}
title={renderTitle(tab)}
selected={snapshotTab === tab}
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
></TabbedPaneTab>;
})}
<div style={{ flex: 'auto' }}></div>
@ -168,7 +165,7 @@ export const SnapshotTab: React.FunctionComponent<{
}}></ToolbarButton>
</Toolbar>}
<div ref={ref} className='snapshot-wrapper'>
{ snapshots.length ? <div className='snapshot-container' style={{
<div className='snapshot-container' style={{
width: snapshotContainerSize.width + 'px',
height: snapshotContainerSize.height + 'px',
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
@ -189,8 +186,7 @@ export const SnapshotTab: React.FunctionComponent<{
</div>
</div>
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
</div> : <div className='no-snapshot'>Action does not have snapshots</div>
}
</div>
</div>
</div>;
};
@ -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,<body style="background: #ddd"></body>';

View File

@ -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('<button>Submit</button>');
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('<button>Submit</button>');
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');
});