feat(tracing): suport loading multiple files in trace viewer (#11880)

This commit is contained in:
Yury Semikhatsky 2022-02-07 17:05:42 -08:00 committed by GitHub
parent 4ef22d3387
commit 1e00218ead
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 182 additions and 78 deletions

View File

@ -219,17 +219,17 @@ program
});
program
.command('show-trace [trace]')
.command('show-trace [trace...]')
.option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium')
.description('Show trace viewer')
.action(function(trace, options) {
.action(function(traces, options) {
if (options.browser === 'cr')
options.browser = 'chromium';
if (options.browser === 'ff')
options.browser = 'firefox';
if (options.browser === 'wk')
options.browser = 'webkit';
showTraceViewer(trace, options.browser, false, 9322).catch(logErrorAndExit);
showTraceViewer(traces, options.browser, false, 9322).catch(logErrorAndExit);
}).addHelpText('afterAll', `
Examples:

View File

@ -26,11 +26,13 @@ import { internalCallMetadata } from '../../instrumentation';
import { createPlaywright } from '../../playwright';
import { ProgressController } from '../../progress';
export async function showTraceViewer(traceUrl: string, browserName: string, headless = false, port?: number): Promise<BrowserContext | undefined> {
if (traceUrl && !traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) {
// eslint-disable-next-line no-console
console.error(`Trace file ${traceUrl} does not exist!`);
process.exit(1);
export async function showTraceViewer(traceUrls: string[], browserName: string, headless = false, port?: number): Promise<BrowserContext | undefined> {
for (const traceUrl of traceUrls) {
if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) {
// eslint-disable-next-line no-console
console.error(`Trace file ${traceUrl} does not exist!`);
process.exit(1);
}
}
const server = new HttpServer();
server.routePrefix('/trace', (request, response) => {
@ -84,6 +86,7 @@ export async function showTraceViewer(traceUrl: string, browserName: string, hea
else
page.on('close', () => process.exit());
await page.mainFrame().goto(internalCallMetadata(), urlPrefix + `/trace/index.html${traceUrl ? '?trace=' + traceUrl : ''}`);
const searchQuery = traceUrls.length ? '?' + traceUrls.map(t => `trace=${t}`).join('&') : '';
await page.mainFrame().goto(internalCallMetadata(), urlPrefix + `/trace/index.html${searchQuery}`);
return context;
}

View File

@ -18,6 +18,7 @@ import type { ResourceSnapshot } from '../../server/trace/common/snapshotTypes';
import * as trace from '../../server/trace/common/traceEvents';
export type ContextEntry = {
traceUrl: string;
startTime: number;
endTime: number;
browserName: string;
@ -33,6 +34,8 @@ export type ContextEntry = {
hasSource: boolean;
};
export type MergedContexts = Pick<ContextEntry, 'startTime' | 'endTime' | 'browserName' | 'platform' | 'wallTime' | 'title' | 'options' | 'pages' | 'actions' | 'events' | 'hasSource'>;
export type PageEntry = {
screencastFrames: {
sha1: string,
@ -43,6 +46,7 @@ export type PageEntry = {
};
export function createEmptyContext(): ContextEntry {
return {
traceUrl: '',
startTime: Number.MAX_SAFE_INTEGER,
endTime: 0,
browserName: '',

View File

@ -37,11 +37,7 @@ async function loadTrace(trace: string, clientId: string, progress: (done: numbe
if (entry)
return entry.traceModel;
const traceModel = new TraceModel();
let url = trace.startsWith('http') || trace.startsWith('blob') ? trace : `file?path=${trace}`;
// Dropbox does not support cors.
if (url.startsWith('https://www.dropbox.com/'))
url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length);
await traceModel.load(url, progress);
await traceModel.load(trace, progress);
const snapshotServer = new SnapshotServer(traceModel.storage());
loadedTraces.set(trace, { traceModel, snapshotServer, clientId });
return traceModel;

View File

@ -37,9 +37,18 @@ export class TraceModel {
this.contextEntry = createEmptyContext();
}
private _formatUrl(trace: string) {
let url = trace.startsWith('http') || trace.startsWith('blob') ? trace : `file?path=${trace}`;
// Dropbox does not support cors.
if (url.startsWith('https://www.dropbox.com/'))
url = 'https://dl.dropboxusercontent.com/' + url.substring('https://www.dropbox.com/'.length);
return url;
}
async load(traceURL: string, progress: (done: number, total: number) => void) {
this.contextEntry.traceUrl = traceURL;
const zipReader = new zipjs.ZipReader( // @ts-ignore
new zipjs.HttpReader(traceURL, { mode: 'cors', preventHeadRequest: true }),
new zipjs.HttpReader(this._formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true }),
{ useWebWorkers: false }) as zip.ZipReader;
let traceEntry: zip.Entry | undefined;
let networkEntry: zip.Entry | undefined;

View File

@ -19,12 +19,12 @@ import { Boundaries, Size } from '../geometry';
import * as React from 'react';
import { useMeasure } from './helpers';
import { upperBound } from '../../uiUtils';
import { ContextEntry, PageEntry } from '../entries';
import { MergedContexts, PageEntry } from '../entries';
const tileSize = { width: 200, height: 45 };
export const FilmStrip: React.FunctionComponent<{
context: ContextEntry,
context: MergedContexts,
boundaries: Boundaries,
previewPoint?: { x: number, clientY: number },
}> = ({ context, boundaries, previewPoint }) => {

View File

@ -16,7 +16,7 @@
import { ResourceSnapshot } from '../../../server/trace/common/snapshotTypes';
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { ContextEntry } from '../entries';
import { ContextEntry, MergedContexts, PageEntry } from '../entries';
const contextSymbol = Symbol('context');
const nextSymbol = Symbol('next');
@ -39,7 +39,7 @@ export function context(action: ActionTraceEvent): ContextEntry {
return (action as any)[contextSymbol];
}
export function next(action: ActionTraceEvent): ActionTraceEvent {
function next(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextSymbol];
}
@ -87,3 +87,22 @@ export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[]
(action as any)[resourcesSymbol] = result;
return result;
}
export function mergeContexts(contexts: ContextEntry[]): MergedContexts {
const newContext: MergedContexts = {
browserName: contexts[0].browserName,
platform: contexts[0].platform,
title: contexts[0].title,
options: contexts[0].options,
wallTime: contexts.map(c => c.wallTime).reduce((prev, cur) => Math.min(prev || Number.MAX_VALUE, cur!), Number.MAX_VALUE),
startTime: contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE),
endTime: contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE),
pages: ([] as PageEntry[]).concat(...contexts.map(c => c.pages)),
actions: ([] as ActionTraceEvent[]).concat(...contexts.map(c => c.actions)),
events: ([] as ActionTraceEvent[]).concat(...contexts.map(c => c.events)),
hasSource: contexts.some(c => c.hasSource)
};
newContext.actions.sort((a1, a2) => a1.metadata.startTime - a2.metadata.startTime);
newContext.events.sort((a1, a2) => a1.metadata.startTime - a2.metadata.startTime);
return newContext;
}

View File

@ -19,6 +19,7 @@ import './tabbedPane.css';
import * as React from 'react';
import { useMeasure } from './helpers';
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { context } from './modelUtil';
export const SnapshotTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined,
@ -39,9 +40,11 @@ export const SnapshotTab: React.FunctionComponent<{
if (action) {
const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotName) {
const traceUrl = new URL(window.location.href).searchParams.get('trace');
snapshotUrl = new URL(`snapshot/${action.metadata.pageId}?trace=${traceUrl}&name=${snapshot.snapshotName}`, window.location.href).toString();
snapshotInfoUrl = new URL(`snapshotInfo/${action.metadata.pageId}?trace=${traceUrl}&name=${snapshot.snapshotName}`, window.location.href).toString();
const params = new URLSearchParams();
params.set('trace', context(action).traceUrl);
params.set('name', snapshot.snapshotName);
snapshotUrl = new URL(`snapshot/${action.metadata.pageId}?${params.toString()}`, window.location.href).toString();
snapshotInfoUrl = new URL(`snapshotInfo/${action.metadata.pageId}?${params.toString()}`, window.location.href).toString();
if (snapshot.snapshotName.includes('action')) {
pointX = action.metadata.point?.x;
pointY = action.metadata.point?.y;

View File

@ -15,14 +15,14 @@
limitations under the License.
*/
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { ContextEntry } from '../entries';
import './timeline.css';
import { Boundaries } from '../geometry';
import * as React from 'react';
import { useMeasure } from './helpers';
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { msToString } from '../../uiUtils';
import { MergedContexts } from '../entries';
import { Boundaries } from '../geometry';
import { FilmStrip } from './filmStrip';
import { useMeasure } from './helpers';
import './timeline.css';
type TimelineBar = {
action?: ActionTraceEvent;
@ -37,7 +37,7 @@ type TimelineBar = {
};
export const Timeline: React.FunctionComponent<{
context: ContextEntry,
context: MergedContexts,
boundaries: Boundaries,
selectedAction: ActionTraceEvent | undefined,
highlightedAction: ActionTraceEvent | undefined,

View File

@ -15,7 +15,7 @@
*/
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { ContextEntry, createEmptyContext } from '../entries';
import { ContextEntry, createEmptyContext, MergedContexts } from '../entries';
import { ActionList } from './actionList';
import { TabbedPane } from './tabbedPane';
import { Timeline } from './timeline';
@ -32,9 +32,9 @@ import { msToString } from '../../uiUtils';
export const Workbench: React.FunctionComponent<{
}> = () => {
const [traceURL, setTraceURL] = React.useState<string>('');
const [uploadedTraceName, setUploadedTraceName] = React.useState<string|null>(null);
const [contextEntry, setContextEntry] = React.useState<ContextEntry>(emptyContext);
const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
const [uploadedTraceNames, setUploadedTraceNames] = React.useState<string[]>([]);
const [contextEntry, setContextEntry] = React.useState<MergedContexts>(emptyContext);
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -44,17 +44,26 @@ export const Workbench: React.FunctionComponent<{
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null);
const [fileForLocalModeError, setFileForLocalModeError] = React.useState<string | null>(null);
const processTraceFile = (file: File) => {
const blobTraceURL = URL.createObjectURL(file);
const processTraceFiles = (files: FileList) => {
const blobUrls = [];
const fileNames = [];
const url = new URL(window.location.href);
url.searchParams.set('trace', blobTraceURL);
url.searchParams.set('traceFileName', file.name);
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file)
continue;
const blobTraceURL = URL.createObjectURL(file);
blobUrls.push(blobTraceURL);
fileNames.push(file.name);
url.searchParams.append('trace', blobTraceURL);
url.searchParams.append('traceFileName', file.name);
}
const href = url.toString();
// Snapshot loaders will inherit the trace url from the query parameters,
// so set it here.
window.history.pushState({}, '', href);
setTraceURL(blobTraceURL);
setUploadedTraceName(file.name);
setTraceURLs(blobUrls);
setUploadedTraceNames(fileNames);
setSelectedAction(undefined);
setDragOver(false);
setProcessingErrorMessage(null);
@ -62,58 +71,66 @@ export const Workbench: React.FunctionComponent<{
const handleDropEvent = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
processTraceFile(event.dataTransfer.files[0]);
processTraceFiles(event.dataTransfer.files);
};
const handleFileInputChange = (event: any) => {
event.preventDefault();
if (!event.target.files)
return;
processTraceFile(event.target.files[0]);
processTraceFiles(event.target.files);
};
React.useEffect(() => {
const newTraceURL = new URL(window.location.href).searchParams.get('trace');
const newTraceURLs = new URL(window.location.href).searchParams.getAll('trace');
// Don't accept file:// URLs - this means we re opened locally.
if (newTraceURL?.startsWith('file:')) {
setFileForLocalModeError(newTraceURL);
return;
for (const url of newTraceURLs) {
if (url.startsWith('file:')) {
setFileForLocalModeError(url || null);
return;
}
}
// Don't re-use blob file URLs on page load (results in Fetch error)
if (newTraceURL && !newTraceURL.startsWith('blob:'))
setTraceURL(newTraceURL);
}, [setTraceURL]);
if (!newTraceURLs.some(url => url.startsWith('blob:')))
setTraceURLs(newTraceURLs);
}, [setTraceURLs]);
React.useEffect(() => {
(async () => {
if (traceURL) {
if (traceURLs.length) {
const swListener = (event: any) => {
if (event.data.method === 'progress')
setProgress(event.data.params);
};
navigator.serviceWorker.addEventListener('message', swListener);
setProgress({ done: 0, total: 1 });
const params = new URLSearchParams();
params.set('trace', traceURL);
if (uploadedTraceName)
params.set('traceFileName', uploadedTraceName);
const response = await fetch(`context?${params.toString()}`);
if (!response.ok) {
setTraceURL('');
setProcessingErrorMessage((await response.json()).error);
return;
const contextEntries: ContextEntry[] = [];
for (let i = 0; i < traceURLs.length; i++) {
const url = traceURLs[i];
const params = new URLSearchParams();
params.set('trace', url);
if (uploadedTraceNames.length)
params.set('traceFileName', uploadedTraceNames[i]);
const response = await fetch(`context?${params.toString()}`);
if (!response.ok) {
setTraceURLs([]);
setProcessingErrorMessage((await response.json()).error);
return;
}
const contextEntry = await response.json() as ContextEntry;
modelUtil.indexModel(contextEntry);
contextEntries.push(contextEntry);
}
const contextEntry = await response.json() as ContextEntry;
navigator.serviceWorker.removeEventListener('message', swListener);
const contextEntry = modelUtil.mergeContexts(contextEntries);
setProgress({ done: 0, total: 0 });
modelUtil.indexModel(contextEntry);
setContextEntry(contextEntry);
setContextEntry(contextEntry!);
} else {
setContextEntry(emptyContext);
}
})();
}, [traceURL, uploadedTraceName]);
}, [traceURLs, uploadedTraceNames]);
const boundaries = { minimum: contextEntry.startTime, maximum: contextEntry.endTime };
@ -199,7 +216,7 @@ export const Workbench: React.FunctionComponent<{
<div>3. Drop the trace from the download shelf into the page</div>
</div>
</div>}
{!dragOver && !fileForLocalModeError && (!traceURL || processingErrorMessage) && <div className='drop-target'>
{!dragOver && !fileForLocalModeError && (!traceURLs.length || processingErrorMessage) && <div className='drop-target'>
<div className='processing-error'>{processingErrorMessage}</div>
<div className='title'>Drop Playwright Trace to load</div>
<div>or</div>

View File

@ -95,12 +95,12 @@ class TraceViewerPage {
}
}
const test = playwrightTest.extend<{ showTraceViewer: (trace: string) => Promise<TraceViewerPage>, runAndTrace: (body: () => Promise<void>) => Promise<TraceViewerPage> }>({
const test = playwrightTest.extend<{ showTraceViewer: (trace: string[]) => Promise<TraceViewerPage>, runAndTrace: (body: () => Promise<void>) => Promise<TraceViewerPage> }>({
showTraceViewer: async ({ playwright, browserName, headless }, use) => {
let browser: Browser;
let contextImpl: any;
await use(async (trace: string) => {
contextImpl = await showTraceViewer(trace, browserName, headless);
await use(async (traces: string[]) => {
contextImpl = await showTraceViewer(traces, browserName, headless);
browser = await playwright.chromium.connectOverCDP({ endpointURL: contextImpl._browser.options.wsEndpoint });
return new TraceViewerPage(browser.contexts()[0].pages()[0]);
});
@ -114,7 +114,7 @@ const test = playwrightTest.extend<{ showTraceViewer: (trace: string) => Promise
await context.tracing.start({ snapshots: true, screenshots: true, sources: true });
await body();
await context.tracing.stop({ path: traceFile });
return showTraceViewer(traceFile);
return showTraceViewer([traceFile]);
});
}
});
@ -180,12 +180,12 @@ test.beforeAll(async function recordTrace({ browser, browserName, browserType, s
});
test('should show empty trace viewer', async ({ showTraceViewer }, testInfo) => {
const traceViewer = await showTraceViewer(testInfo.outputPath());
const traceViewer = await showTraceViewer([testInfo.outputPath()]);
await expect(traceViewer.page).toHaveTitle('Playwright Trace Viewer');
});
test('should open simple trace viewer', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await expect(traceViewer.actionTitles).toHaveText([
/browserContext.newPage/,
/page.gotodata:text\/html,<html>Hello world<\/html>/,
@ -206,7 +206,7 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => {
});
test('should contain action info', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.click');
const logLines = await traceViewer.callLines.allTextContents();
expect(logLines.length).toBeGreaterThan(10);
@ -215,14 +215,14 @@ test('should contain action info', async ({ showTraceViewer }) => {
});
test('should render events', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
const events = await traceViewer.eventBars();
expect(events).toContain('page_console');
});
test('should render console', async ({ showTraceViewer, browserName }) => {
test.fixme(browserName === 'firefox', 'Firefox generates stray console message for page error');
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.evaluate');
await traceViewer.showConsoleTab();
@ -233,7 +233,7 @@ test('should render console', async ({ showTraceViewer, browserName }) => {
test('should open console errors on click', async ({ showTraceViewer, browserName }) => {
test.fixme(browserName === 'firefox', 'Firefox generates stray console message for page error');
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
expect(await traceViewer.actionIconsText('page.evaluate')).toEqual(['2', '1']);
expect(await traceViewer.page.isHidden('.console-tab')).toBeTruthy();
await (await traceViewer.actionIcons('page.evaluate')).click();
@ -241,7 +241,7 @@ test('should open console errors on click', async ({ showTraceViewer, browserNam
});
test('should show params and return value', async ({ showTraceViewer, browserName }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.evaluate');
await expect(traceViewer.callLines).toHaveText([
/page.evaluate/,
@ -255,7 +255,7 @@ test('should show params and return value', async ({ showTraceViewer, browserNam
});
test('should have correct snapshot size', async ({ showTraceViewer }, testInfo) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.setViewport');
await traceViewer.selectSnapshot('Before');
await expect(traceViewer.snapshotContainer).toHaveCSS('width', '1280px');
@ -266,7 +266,7 @@ test('should have correct snapshot size', async ({ showTraceViewer }, testInfo)
});
test('should have correct stack trace', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.click');
await traceViewer.showSourceTab();
@ -277,7 +277,7 @@ test('should have correct stack trace', async ({ showTraceViewer }) => {
});
test('should have network requests', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('http://localhost');
await traceViewer.showNetworkTab();
await expect(traceViewer.networkRequests).toHaveText([
@ -573,7 +573,7 @@ test('should highlight target elements', async ({ page, runAndTrace, browserName
});
test('should show action source', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.selectAction('page.click');
const page = traceViewer.page;
@ -613,7 +613,7 @@ test('should follow redirects', async ({ page, runAndTrace, server, asset }) =>
});
test('should include metainfo', async ({ showTraceViewer, browserName }) => {
const traceViewer = await showTraceViewer(traceFile);
const traceViewer = await showTraceViewer([traceFile]);
await traceViewer.page.locator('text=Metadata').click();
const callLine = traceViewer.page.locator('.call-line');
await expect(callLine.locator('text=start time')).toHaveText(/start time: [\d/,: ]+/);
@ -626,3 +626,56 @@ test('should include metainfo', async ({ showTraceViewer, browserName }) => {
await expect(callLine.locator('text=actions')).toHaveText(/actions: [\d]+/);
await expect(callLine.locator('text=events')).toHaveText(/events: [\d]+/);
});
test('should open two trace files', async ({ context, page, request, server, showTraceViewer }, testInfo) => {
await (request as any)._tracing.start({ snapshots: true });
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
{
const response = await request.get(server.PREFIX + '/simple.json');
await expect(response).toBeOK();
}
await page.goto(server.PREFIX + '/input/button.html');
{
const response = await request.head(server.PREFIX + '/simplezip.json');
await expect(response).toBeOK();
}
await page.click('button');
await page.click('button');
{
const response = await request.post(server.PREFIX + '/one-style.css');
expect(response).toBeOK();
}
const apiTrace = testInfo.outputPath('api.zip');
const contextTrace = testInfo.outputPath('context.zip');
await (request as any)._tracing.stop({ path: apiTrace });
await context.tracing.stop({ path: contextTrace });
const traceViewer = await showTraceViewer([contextTrace, apiTrace]);
await traceViewer.selectAction('apiRequestContext.head');
await traceViewer.selectAction('apiRequestContext.get');
await traceViewer.selectAction('apiRequestContext.post');
await expect(traceViewer.actionTitles).toHaveText([
`apiRequestContext.get`,
`page.gotohttp://localhost:${server.PORT}/input/button.html`,
`apiRequestContext.head`,
`page.clickbutton`,
`page.clickbutton`,
`apiRequestContext.post`,
]);
await traceViewer.page.locator('text=Metadata').click();
const callLine = traceViewer.page.locator('.call-line');
// Should get metadata from the context trace
await expect(callLine.locator('text=start time')).toHaveText(/start time: [\d/,: ]+/);
// duration in the metatadata section
await expect(callLine.locator('text=duration').first()).toHaveText(/duration: [\dms]+/);
await expect(callLine.locator('text=engine')).toHaveText(/engine: [\w]+/);
await expect(callLine.locator('text=platform')).toHaveText(/platform: [\w]+/);
await expect(callLine.locator('text=width')).toHaveText(/width: [\d]+/);
await expect(callLine.locator('text=height')).toHaveText(/height: [\d]+/);
await expect(callLine.locator('text=pages')).toHaveText(/pages: 1/);
await expect(callLine.locator('text=actions')).toHaveText(/actions: 6/);
await expect(callLine.locator('text=events')).toHaveText(/events: [\d]+/);
});