cherry-pick(#21775): chore: show snapshots for sync assertions
This commit is contained in:
parent
d806c98009
commit
3e8b14031b
|
|
@ -104,7 +104,7 @@ export class SnapshotRenderer {
|
||||||
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
|
const prefix = snapshot.doctype ? `<!DOCTYPE ${snapshot.doctype}>` : '';
|
||||||
html = prefix + [
|
html = prefix + [
|
||||||
'<style>*,*::before,*::after { visibility: hidden }</style>',
|
'<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>`
|
`<script>${snapshotScript()}</script>`
|
||||||
].join('') + html;
|
].join('') + html;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ import type { ContextEntry, PageEntry } from '../entries';
|
||||||
import type { SerializedError, StackFrame } from '@protocol/channels';
|
import type { SerializedError, StackFrame } from '@protocol/channels';
|
||||||
|
|
||||||
const contextSymbol = Symbol('context');
|
const contextSymbol = Symbol('context');
|
||||||
const nextSymbol = Symbol('next');
|
const nextInContextSymbol = Symbol('next');
|
||||||
|
const prevInListSymbol = Symbol('prev');
|
||||||
const eventsSymbol = Symbol('events');
|
const eventsSymbol = Symbol('events');
|
||||||
const resourcesSymbol = Symbol('resources');
|
const resourcesSymbol = Symbol('resources');
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ function indexModel(context: ContextEntry) {
|
||||||
for (let i = 0; i < context.actions.length; ++i) {
|
for (let i = 0; i < context.actions.length; ++i) {
|
||||||
const action = context.actions[i] as any;
|
const action = context.actions[i] as any;
|
||||||
action[contextSymbol] = context;
|
action[contextSymbol] = context;
|
||||||
action[nextSymbol] = context.actions[i + 1];
|
action[nextInContextSymbol] = context.actions[i + 1];
|
||||||
}
|
}
|
||||||
for (const event of context.events)
|
for (const event of context.events)
|
||||||
(event as any)[contextSymbol] = context;
|
(event as any)[contextSymbol] = context;
|
||||||
|
|
@ -114,15 +115,22 @@ function dedupeActions(actions: ActionTraceEvent[]) {
|
||||||
result.push(expectAction);
|
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 {
|
export function context(action: ActionTraceEvent): ContextEntry {
|
||||||
return (action as any)[contextSymbol];
|
return (action as any)[contextSymbol];
|
||||||
}
|
}
|
||||||
|
|
||||||
function next(action: ActionTraceEvent): ActionTraceEvent {
|
function nextInContext(action: ActionTraceEvent): ActionTraceEvent {
|
||||||
return (action as any)[nextSymbol];
|
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 } {
|
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
|
||||||
|
|
@ -149,7 +157,7 @@ export function eventsForAction(action: ActionTraceEvent): EventTraceEvent[] {
|
||||||
if (result)
|
if (result)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
const nextAction = next(action);
|
const nextAction = nextInContext(action);
|
||||||
result = context(action).events.filter(event => {
|
result = context(action).events.filter(event => {
|
||||||
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
|
return event.time >= action.startTime && (!nextAction || event.time < nextAction.startTime);
|
||||||
});
|
});
|
||||||
|
|
@ -162,7 +170,7 @@ export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[]
|
||||||
if (result)
|
if (result)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
const nextAction = next(action);
|
const nextAction = nextInContext(action);
|
||||||
result = context(action).resources.filter(resource => {
|
result = context(action).resources.filter(resource => {
|
||||||
return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.startTime && (!nextAction || resource._monotonicTime < nextAction.startTime);
|
return typeof resource._monotonicTime === 'number' && resource._monotonicTime > action.startTime && (!nextAction || resource._monotonicTime < nextAction.startTime);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import './snapshotTab.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMeasure } from './helpers';
|
import { useMeasure } from './helpers';
|
||||||
import type { ActionTraceEvent } from '@trace/trace';
|
import type { ActionTraceEvent } from '@trace/trace';
|
||||||
import { context } from './modelUtil';
|
import { context, prevInList } from './modelUtil';
|
||||||
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||||
import { Toolbar } from '@web/components/toolbar';
|
import { Toolbar } from '@web/components/toolbar';
|
||||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
import { ToolbarButton } from '@web/components/toolbarButton';
|
||||||
|
|
@ -36,49 +36,46 @@ export const SnapshotTab: React.FunctionComponent<{
|
||||||
testIdAttributeName: string,
|
testIdAttributeName: string,
|
||||||
}> = ({ action, sdkLanguage, testIdAttributeName }) => {
|
}> = ({ action, sdkLanguage, testIdAttributeName }) => {
|
||||||
const [measure, ref] = useMeasure<HTMLDivElement>();
|
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 [isInspecting, setIsInspecting] = React.useState(false);
|
||||||
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
const [highlightedLocator, setHighlightedLocator] = React.useState<string>('');
|
||||||
const [pickerVisible, setPickerVisible] = React.useState(false);
|
const [pickerVisible, setPickerVisible] = React.useState(false);
|
||||||
|
|
||||||
const { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
|
const { snapshots } = React.useMemo(() => {
|
||||||
const actionSnapshot = action?.inputSnapshot || action?.afterSnapshot;
|
if (!action)
|
||||||
const snapshots = [
|
return { 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 }[];
|
|
||||||
|
|
||||||
let snapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
|
// if the action has no beforeSnapshot, use the last available afterSnapshot.
|
||||||
let popoutUrl: string | undefined;
|
let beforeSnapshot = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined;
|
||||||
let snapshotInfoUrl: string | undefined;
|
let a = action;
|
||||||
let pointX: number | undefined;
|
while (!beforeSnapshot && a) {
|
||||||
let pointY: number | undefined;
|
a = prevInList(a);
|
||||||
if (action) {
|
beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { snapshots, snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl };
|
const afterSnapshot = action.afterSnapshot ? { action, snapshotName: action.afterSnapshot } : beforeSnapshot;
|
||||||
}, [action, snapshotIndex]);
|
const actionSnapshot = action.inputSnapshot ? { action, snapshotName: action.inputSnapshot } : afterSnapshot;
|
||||||
|
return { snapshots: { action: actionSnapshot, before: beforeSnapshot, after: afterSnapshot } };
|
||||||
|
}, [action]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const { snapshotInfoUrl, snapshotUrl, pointX, pointY, popoutUrl } = React.useMemo(() => {
|
||||||
if (snapshots.length >= 1 && snapshotIndex >= snapshots.length)
|
const snapshot = snapshots[snapshotTab];
|
||||||
setSnapshotIndex(snapshots.length - 1);
|
if (!snapshot)
|
||||||
}, [snapshotIndex, snapshots]);
|
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 iframeRef = React.useRef<HTMLIFrameElement>(null);
|
||||||
const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' });
|
const [snapshotInfo, setSnapshotInfo] = React.useState({ viewport: kDefaultViewport, url: '' });
|
||||||
|
|
@ -141,12 +138,12 @@ export const SnapshotTab: React.FunctionComponent<{
|
||||||
setIsInspecting(!pickerVisible);
|
setIsInspecting(!pickerVisible);
|
||||||
}}>Pick locator</ToolbarButton>
|
}}>Pick locator</ToolbarButton>
|
||||||
<div style={{ width: 5 }}></div>
|
<div style={{ width: 5 }}></div>
|
||||||
{snapshots.map((snapshot, index) => {
|
{['action', 'before', 'after'].map(tab => {
|
||||||
return <TabbedPaneTab
|
return <TabbedPaneTab
|
||||||
id={snapshot.title}
|
id={tab}
|
||||||
title={renderTitle(snapshot.title)}
|
title={renderTitle(tab)}
|
||||||
selected={snapshotIndex === index}
|
selected={snapshotTab === tab}
|
||||||
onSelect={() => setSnapshotIndex(index)}
|
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
|
||||||
></TabbedPaneTab>;
|
></TabbedPaneTab>;
|
||||||
})}
|
})}
|
||||||
<div style={{ flex: 'auto' }}></div>
|
<div style={{ flex: 'auto' }}></div>
|
||||||
|
|
@ -168,7 +165,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
||||||
}}></ToolbarButton>
|
}}></ToolbarButton>
|
||||||
</Toolbar>}
|
</Toolbar>}
|
||||||
<div ref={ref} className='snapshot-wrapper'>
|
<div ref={ref} className='snapshot-wrapper'>
|
||||||
{ snapshots.length ? <div className='snapshot-container' style={{
|
<div className='snapshot-container' style={{
|
||||||
width: snapshotContainerSize.width + 'px',
|
width: snapshotContainerSize.width + 'px',
|
||||||
height: snapshotContainerSize.height + 'px',
|
height: snapshotContainerSize.height + 'px',
|
||||||
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
|
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
|
||||||
|
|
@ -189,8 +186,7 @@ export const SnapshotTab: React.FunctionComponent<{
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
|
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
|
||||||
</div> : <div className='no-snapshot'>Action does not have snapshots</div>
|
</div>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
@ -215,8 +211,13 @@ export const InspectModeController: React.FunctionComponent<{
|
||||||
}> = ({ iframe, isInspecting, sdkLanguage, testIdAttributeName, highlightedLocator, setHighlightedLocator }) => {
|
}> = ({ iframe, isInspecting, sdkLanguage, testIdAttributeName, highlightedLocator, setHighlightedLocator }) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const win = iframe?.contentWindow as any;
|
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;
|
return;
|
||||||
|
}
|
||||||
let recorder: Recorder | undefined = win._recorder;
|
let recorder: Recorder | undefined = win._recorder;
|
||||||
if (!recorder) {
|
if (!recorder) {
|
||||||
const injectedScript = new InjectedScript(win, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
|
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 kDefaultViewport = { width: 1280, height: 720 };
|
||||||
|
const kBlankSnapshotUrl = 'data:text/html,<body style="background: #ddd"></body>';
|
||||||
|
|
|
||||||
95
tests/playwright-test/ui-mode-trace.spec.ts
Normal file
95
tests/playwright-test/ui-mode-trace.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue