feat(ui-mode): show step breadcrumbs in Trace Viewer

This commit is contained in:
Max Schmitt 2024-08-04 12:11:40 +02:00
parent d0c840f639
commit 8663a6dade
6 changed files with 80 additions and 30 deletions

View file

@ -24,6 +24,7 @@ import type { StackFrame } from '@protocol/channels';
const contextSymbol = Symbol('context'); const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('next'); const nextInContextSymbol = Symbol('next');
const prevInListSymbol = Symbol('prev'); const prevInListSymbol = Symbol('prev');
const parentActionSymbol = Symbol('parent');
const eventsSymbol = Symbol('events'); const eventsSymbol = Symbol('events');
export type SourceLocation = { export type SourceLocation = {
@ -195,8 +196,11 @@ function mergeActionsAndUpdateTiming(contexts: ContextEntry[]) {
return a1.startTime - a2.startTime; return a1.startTime - a2.startTime;
}); });
for (let i = 1; i < result.length; ++i) for (let i = 1; i < result.length; ++i) {
(result[i] as any)[prevInListSymbol] = result[i - 1]; (result[i] as any)[prevInListSymbol] = result[i - 1];
if (result[i].parentId)
(result[i] as any)[parentActionSymbol] = result.find(a => a.callId === result[i].parentId);
}
return result; return result;
} }
@ -356,6 +360,10 @@ export function prevInList(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[prevInListSymbol]; return (action as any)[prevInListSymbol];
} }
export function parentAction(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[parentActionSymbol];
}
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
let errors = 0; let errors = 0;
let warnings = 0; let warnings = 0;

View file

@ -17,7 +17,7 @@
import './snapshotTab.css'; import './snapshotTab.css';
import * as React from 'react'; import * as React from 'react';
import type { ActionTraceEvent } from '@trace/trace'; import type { ActionTraceEvent } from '@trace/trace';
import { context, prevInList } from './modelUtil'; import { context, parentAction, prevInList } from './modelUtil';
import { Toolbar } from '@web/components/toolbar'; import { Toolbar } from '@web/components/toolbar';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
import { clsx, useMeasure } from '@web/uiUtils'; import { clsx, useMeasure } from '@web/uiUtils';
@ -50,7 +50,7 @@ export const SnapshotTab: React.FunctionComponent<{
// if the action has no beforeSnapshot, use the last available afterSnapshot. // if the action has no beforeSnapshot, use the last available afterSnapshot.
let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined; let beforeSnapshot: Snapshot | undefined = action.beforeSnapshot ? { action, snapshotName: action.beforeSnapshot } : undefined;
let a = action; let a: ActionTraceEvent | undefined = action;
while (!beforeSnapshot && a) { while (!beforeSnapshot && a) {
a = prevInList(a); a = prevInList(a);
beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined; beforeSnapshot = a?.afterSnapshot ? { action: a, snapshotName: a?.afterSnapshot } : undefined;
@ -180,27 +180,51 @@ export const SnapshotTab: React.FunctionComponent<{
setHighlightedLocator={setHighlightedLocator} setHighlightedLocator={setHighlightedLocator}
iframe={iframeRef1.current} iframe={iframeRef1.current}
iteration={loadingRef.current.iteration} /> iteration={loadingRef.current.iteration} />
<Toolbar> <div className='hbox' style={{ flex: '0 0 auto' }}>
<ToolbarButton className='pick-locator' title='Pick locator' icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} /> <Toolbar style={{
{['action', 'before', 'after'].map(tab => { flex: '0 1 0',
return <TabbedPaneTab minWidth: 'min-content',
id={tab} flexGrow: 1
title={renderTitle(tab)} }}>
selected={snapshotTab === tab} <ToolbarButton className='pick-locator' title='Pick locator' icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} />
onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')} {['action', 'before', 'after'].map(tab => {
></TabbedPaneTab>; return <TabbedPaneTab
})} id={tab}
<div style={{ flex: 'auto' }}></div> title={renderTitle(tab)}
<ToolbarButton icon='link-external' title='Open snapshot in a new tab' disabled={!popoutUrl} onClick={() => { selected={snapshotTab === tab}
if (!openPage) onSelect={() => setSnapshotTab(tab as 'action' | 'before' | 'after')}
openPage = window.open; ></TabbedPaneTab>;
const win = openPage(popoutUrl || '', '_blank'); })}
win?.addEventListener('DOMContentLoaded', () => { </Toolbar>
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); <Toolbar
new ConsoleAPI(injectedScript); style={{
}); whiteSpace: 'nowrap',
}}></ToolbarButton> overflow: 'hidden',
</Toolbar> textOverflow: 'ellipsis',
direction: 'rtl',
flexGrow: 0,
flexShrink: 1,
}}
data-testid='snapshot-breadcrumb'>
{actionToBreadcrumb(action)}
</Toolbar>
<Toolbar style={{
flex: '0 1 0',
minWidth: 'min-content',
flexGrow: 1,
justifyContent: 'flex-end',
}}>
<ToolbarButton icon='link-external' title='Open snapshot in a new tab' disabled={!popoutUrl} onClick={() => {
if (!openPage)
openPage = window.open;
const win = openPage(popoutUrl || '', '_blank');
win?.addEventListener('DOMContentLoaded', () => {
const injectedScript = new InjectedScript(win as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []);
new ConsoleAPI(injectedScript);
});
}}></ToolbarButton>
</Toolbar>
</div>
<div ref={ref} className='snapshot-wrapper'> <div ref={ref} className='snapshot-wrapper'>
<div className='snapshot-container' style={{ <div className='snapshot-container' style={{
width: snapshotContainerSize.width + 'px', width: snapshotContainerSize.width + 'px',
@ -217,6 +241,17 @@ export const SnapshotTab: React.FunctionComponent<{
</div>; </div>;
}; };
function actionToBreadcrumb(action: ActionTraceEvent | undefined): React.ReactNode {
if (!action)
return '';
const parts = [];
while (action) {
parts.push(action.apiName);
action = parentAction(action);
}
return parts.reverse().join(' ');
}
function renderTitle(snapshotTitle: string): string { function renderTitle(snapshotTitle: string): string {
if (snapshotTitle === 'before') if (snapshotTitle === 'before')
return 'Before'; return 'Before';

View file

@ -22,15 +22,14 @@ type ToolbarProps = {
noShadow?: boolean; noShadow?: boolean;
noMinHeight?: boolean; noMinHeight?: boolean;
className?: string; className?: string;
onClick?: (e: React.MouseEvent) => void; } & React.HTMLAttributes<HTMLDivElement>;
};
export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({ export const Toolbar: React.FC<React.PropsWithChildren<ToolbarProps>> = ({
noShadow, noShadow,
children, children,
noMinHeight, noMinHeight,
className, className,
onClick, ...props
}) => { }) => {
return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className)} onClick={onClick}>{children}</div>; return <div className={clsx('toolbar', noShadow && 'no-shadow', noMinHeight && 'no-min-height', className)} {...props}>{children}</div>;
}; };

View file

@ -289,6 +289,14 @@ test('should show snapshot URL', async ({ page, runAndTrace, server }) => {
}); });
test('should popup snapshot', async ({ page, runAndTrace, server }) => { test('should popup snapshot', async ({ page, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE);
});
await traceViewer.snapshotFrame('page.goto');
await expect(traceViewer.page.getByTestId('snapshot-breadcrumb')).toHaveText('page.goto');
});
test('should show active action title', async ({ page, runAndTrace, server }) => {
const traceViewer = await runAndTrace(async () => { const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
await page.setContent('hello'); await page.setContent('hello');

View file

@ -91,7 +91,7 @@ test('should contain string attachment', async ({ runUITest }) => {
await page.getByTitle('Run all').click(); await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
await page.getByText('Attachments').click(); await page.getByText('Attachments').click();
await page.getByText('attach "note"', { exact: true }).click(); await page.getByTestId('actions-tree').getByRole('listitem').filter({ hasText: 'attach "note"' }).click();
const downloadPromise = page.waitForEvent('download'); const downloadPromise = page.waitForEvent('download');
await page.locator('.expandable-title', { hasText: 'note' }).getByRole('link').click(); await page.locator('.expandable-title', { hasText: 'note' }).getByRole('link').click();
const download = await downloadPromise; const download = await downloadPromise;

View file

@ -112,7 +112,7 @@ test('should locate sync assertions in source', async ({ runUITest }) => {
}); });
await page.getByText('trace test').dblclick(); await page.getByText('trace test').dblclick();
await page.getByText('expect.toBe').click(); await page.getByTestId('actions-tree').getByRole('listitem').filter({ hasText: 'expect.toBe' }).click();
await expect( await expect(
page.locator('.CodeMirror .source-line-running'), page.locator('.CodeMirror .source-line-running'),