chore: render test steps in the trace (#22837)

This commit is contained in:
Pavel Feldman 2023-05-05 15:12:18 -07:00 committed by GitHub
parent 641e223ca8
commit efad19b332
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 584 additions and 326 deletions

View File

@ -57,15 +57,15 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
}
async _newContextForReuse(options: BrowserContextOptions = {}): Promise<BrowserContext> {
for (const context of this._contexts) {
await this._wrapApiCall(async () => {
return await this._wrapApiCall(async () => {
for (const context of this._contexts) {
await this._browserType._willCloseContext(context);
}, true);
for (const page of context.pages())
page._onClose();
context._onClose();
}
return await this._innerNewContext(options, true);
for (const page of context.pages())
page._onClose();
context._onClose();
}
return await this._innerNewContext(options, true);
}, true);
}
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {

View File

@ -20,12 +20,11 @@ import { maybeFindValidator, ValidationError, type ValidatorContext } from '../p
import { debugLogger } from '../common/debugLogger';
import type { ExpectZone, ParsedStackTrace } from '../utils/stackTrace';
import { captureRawStack, captureLibraryStackTrace } from '../utils/stackTrace';
import { isString, isUnderTest } from '../utils';
import { isUnderTest } from '../utils';
import { zones } from '../utils/zones';
import type { ClientInstrumentation } from './clientInstrumentation';
import type { Connection } from './connection';
import type { Logger } from './types';
import { asLocator } from '../utils/isomorphic/locatorGenerators';
type Listener = (...args: any[]) => void;
@ -145,7 +144,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
const { stackTrace, csi, callCookie, wallTime } = apiZone.reported ? { csi: undefined, callCookie: undefined, stackTrace: null, wallTime: undefined } : apiZone;
apiZone.reported = true;
if (csi && stackTrace && stackTrace.apiName)
csi.onApiCallBegin(renderCallWithParams(stackTrace.apiName, params), stackTrace, wallTime, callCookie);
csi.onApiCallBegin(stackTrace.apiName, params, stackTrace, wallTime, callCookie);
return this._connection.sendMessageToServer(this, this._type, prop, validator(params, '', { tChannelImpl: tChannelImplToWire, binary: this._connection.isRemote() ? 'toBase64' : 'buffer' }), stackTrace, wallTime);
});
};
@ -166,6 +165,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
return func(apiZone);
const stackTrace = captureLibraryStackTrace(stack);
isInternal = isInternal || this._type === 'LocalUtils';
if (isInternal)
delete stackTrace.apiName;
@ -227,29 +227,6 @@ function logApiCall(logger: Logger | undefined, message: string, isNested: boole
debugLogger.log('api', message);
}
const paramsToRender = ['url', 'selector', 'text', 'key'];
function renderCallWithParams(apiName: string, params: any) {
const paramsArray = [];
if (params) {
for (const name of paramsToRender) {
if (!(name in params))
continue;
let value;
if (name === 'selector' && isString(params[name]) && params[name].startsWith('internal:')) {
const getter = asLocator('javascript', params[name], false, true);
apiName = apiName.replace(/^locator\./, 'locator.' + getter + '.');
apiName = apiName.replace(/^page\./, 'page.' + getter + '.');
apiName = apiName.replace(/^frame\./, 'frame.' + getter + '.');
} else {
value = params[name];
paramsArray.push(value);
}
}
}
const paramsText = paramsArray.length ? '(' + paramsArray.join(', ') + ')' : '';
return apiName + paramsText;
}
function tChannelImplToWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
if (arg._object instanceof ChannelOwner && (names === '*' || names.includes(arg._object._type)))
return { guid: arg._object._guid };

View File

@ -22,7 +22,7 @@ export interface ClientInstrumentation {
addListener(listener: ClientInstrumentationListener): void;
removeListener(listener: ClientInstrumentationListener): void;
removeAllListeners(): void;
onApiCallBegin(apiCall: string, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void;
onApiCallBegin(apiCall: string, params: Record<string, any>, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void;
onApiCallEnd(userData: any, error?: Error): void;
onDidCreateBrowserContext(context: BrowserContext): Promise<void>;
onDidCreateRequestContext(context: APIRequestContext): Promise<void>;
@ -32,7 +32,7 @@ export interface ClientInstrumentation {
}
export interface ClientInstrumentationListener {
onApiCallBegin?(apiCall: string, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void;
onApiCallBegin?(apiCall: string, params: Record<string, any>, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void;
onApiCallEnd?(userData: any, error?: Error): void;
onDidCreateBrowserContext?(context: BrowserContext): Promise<void>;
onDidCreateRequestContext?(context: APIRequestContext): Promise<void>;

View File

@ -40,3 +40,4 @@ export * from './traceUtils';
export * from './userAgent';
export * from './zipFile';
export * from './zones';
export * from './isomorphic/locatorGenerators';

View File

@ -520,6 +520,6 @@ const generators: Record<Language, LocatorFactory> = {
csharp: new CSharpLocatorFactory(),
};
export function isRegExp(obj: any): obj is RegExp {
function isRegExp(obj: any): obj is RegExp {
return obj instanceof RegExp;
}

View File

@ -139,21 +139,22 @@ export async function saveTraceFile(fileName: string, traceEvents: TraceEvent[],
});
}
export function createBeforeActionTraceEventForExpect(callId: string, apiName: string, wallTime: number, expected: any, stack: StackFrame[]): BeforeActionTraceEvent {
export function createBeforeActionTraceEventForStep(callId: string, parentId: string | undefined, apiName: string, params: Record<string, any> | undefined, wallTime: number, stack: StackFrame[]): BeforeActionTraceEvent {
return {
type: 'before',
callId,
parentId,
wallTime,
startTime: monotonicTime(),
class: 'Test',
method: 'step',
apiName,
params: { expected: generatePreview(expected) },
params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])),
stack,
};
}
export function createAfterActionTraceEventForExpect(callId: string, attachments: AfterActionTraceEvent['attachments'], error?: SerializedError['error']): AfterActionTraceEvent {
export function createAfterActionTraceEventForStep(callId: string, attachments: AfterActionTraceEvent['attachments'], error?: SerializedError['error']): AfterActionTraceEvent {
return {
type: 'after',
callId,

View File

@ -187,9 +187,9 @@ const kPlaywrightCoveragePrefix = path.resolve(__dirname, '../../../../tests/con
export function belongsToNodeModules(file: string) {
if (file.includes(`${path.sep}node_modules${path.sep}`))
return true;
if (file.startsWith(kPlaywrightInternalPrefix))
if (file.startsWith(kPlaywrightInternalPrefix) && file.endsWith('.js'))
return true;
if (file.startsWith(kPlaywrightCoveragePrefix))
if (file.startsWith(kPlaywrightCoveragePrefix) && file.endsWith('.js'))
return true;
return false;
}

View File

@ -18,7 +18,7 @@ import * as fs from 'fs';
import * as path from 'path';
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
import * as playwrightLibrary from 'playwright-core';
import { createGuid, debugMode, addInternalStackPrefix, mergeTraceFiles, saveTraceFile, removeFolders } from 'playwright-core/lib/utils';
import { createGuid, debugMode, addInternalStackPrefix, mergeTraceFiles, saveTraceFile, removeFolders, isString, asLocator } from 'playwright-core/lib/utils';
import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test';
import type { TestInfoImpl } from './worker/testInfo';
import { rootTestType } from './common/testType';
@ -269,14 +269,16 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
let artifactsRecorder: ArtifactsRecorder | undefined;
const csiListener: ClientInstrumentationListener = {
onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any) => {
onApiCallBegin: (apiName: string, params: Record<string, any>, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any) => {
const testInfo = currentTestInfo();
if (!testInfo || apiCall.startsWith('expect.') || apiCall.includes('setTestIdAttribute'))
if (!testInfo || apiName.startsWith('expect.') || apiName.includes('setTestIdAttribute'))
return { userObject: null };
const step = testInfo._addStep({
location: stackTrace?.frames[0] as any,
category: 'pw:api',
title: apiCall,
title: renderApiCall(apiName, params),
apiName,
params,
wallTime,
laxParent: true,
});
@ -745,6 +747,30 @@ class ArtifactsRecorder {
}
}
const paramsToRender = ['url', 'selector', 'text', 'key'];
function renderApiCall(apiName: string, params: any) {
const paramsArray = [];
if (params) {
for (const name of paramsToRender) {
if (!(name in params))
continue;
let value;
if (name === 'selector' && isString(params[name]) && params[name].startsWith('internal:')) {
const getter = asLocator('javascript', params[name], false, true);
apiName = apiName.replace(/^locator\./, 'locator.' + getter + '.');
apiName = apiName.replace(/^page\./, 'page.' + getter + '.');
apiName = apiName.replace(/^frame\./, 'frame.' + getter + '.');
} else {
value = params[name];
paramsArray.push(value);
}
}
}
const paramsText = paramsArray.length ? '(' + paramsArray.join(', ') + ')' : '';
return apiName + paramsText;
}
export const test = _baseTest.extend<TestFixtures, WorkerFixtures>(playwrightFixtures);
export default test;

View File

@ -16,8 +16,6 @@
import {
captureRawStack,
createAfterActionTraceEventForExpect,
createBeforeActionTraceEventForExpect,
isString,
pollAgainstTimeout } from 'playwright-core/lib/utils';
import type { ExpectZone } from 'playwright-core/lib/utils';
@ -48,7 +46,7 @@ import {
toPass
} from './matchers';
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot';
import type { Expect, TestInfo } from '../../types/test';
import type { Expect } from '../../types/test';
import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals';
import { filteredStackTrace, serializeError, stringifyStackFrames, trimLongString } from '../util';
import {
@ -58,7 +56,6 @@ import {
printReceived,
} from '../common/expectBundle';
import { zones } from 'playwright-core/lib/utils';
import type { AfterActionTraceEvent } from '../../../trace/src/trace';
// from expect/build/types
export type SyncExpectationResult = {
@ -79,8 +76,6 @@ export type SyncExpectationResult = {
// The replacement is compatible with pretty-format package.
const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&');
let lastCallId = 0;
export const printReceivedStringContainExpectedSubstring = (
received: string,
start: number,
@ -254,19 +249,14 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`;
const wallTime = Date.now();
const initialAttachments = new Set(testInfo.attachments.slice());
const step = testInfo._addStep({
location: stackFrames[0],
category: 'expect',
title: trimLongString(customMessage || defaultTitle, 1024),
params: args[0] ? { expected: args[0] } : undefined,
wallTime
});
const generateTraceEvent = matcherName !== 'poll' && matcherName !== 'toPass';
const callId = ++lastCallId;
if (generateTraceEvent)
testInfo._traceEvents.push(createBeforeActionTraceEventForExpect(`expect@${callId}`, defaultTitle, wallTime, args[0], stackFrames));
const reportStepError = (jestError: Error) => {
const message = jestError.message;
if (customMessage) {
@ -291,10 +281,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
}
const serializerError = serializeError(jestError);
if (generateTraceEvent) {
const error = { name: jestError.name, message: jestError.message, stack: jestError.stack };
testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments), error));
}
step.complete({ error: serializerError });
if (this._info.isSoft)
testInfo._failWithError(serializerError, false /* isHardError */);
@ -303,8 +289,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
};
const finalizer = () => {
if (generateTraceEvent)
testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments)));
step.complete({});
};
@ -375,15 +359,4 @@ function computeArgsSuffix(matcherName: string, args: any[]) {
return value ? `(${value})` : '';
}
function serializeAttachments(attachments: TestInfo['attachments'], initialAttachments: Set<TestInfo['attachments'][0]>): AfterActionTraceEvent['attachments'] {
return attachments.filter(a => !initialAttachments.has(a)).map(a => {
return {
name: a.name,
contentType: a.contentType,
path: a.path,
body: a.body?.toString('base64'),
};
});
}
expectLibrary.extend(customMatchers);

View File

@ -287,7 +287,7 @@ export function fileIsModule(file: string): boolean {
return folderIsModule(folder);
}
export function folderIsModule(folder: string): boolean {
function folderIsModule(folder: string): boolean {
const packageJsonPath = getPackageJsonPath(folder);
if (!packageJsonPath)
return false;

View File

@ -16,7 +16,7 @@
import fs from 'fs';
import path from 'path';
import { captureRawStack, monotonicTime, zones } from 'playwright-core/lib/utils';
import { captureRawStack, createAfterActionTraceEventForStep, createBeforeActionTraceEventForStep, monotonicTime, zones } from 'playwright-core/lib/utils';
import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test';
import type { StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc';
import type { TestCase } from '../common/test';
@ -36,6 +36,8 @@ interface TestStepInternal {
steps: TestStepInternal[];
laxParent?: boolean;
endWallTime?: number;
apiName?: string;
params?: Record<string, any>;
error?: TestInfoError;
}
@ -228,6 +230,8 @@ export class TestInfoImpl implements TestInfo {
isLaxParent = !!parentStep;
}
const initialAttachments = new Set(this.attachments);
const step: TestStepInternal = {
stepId,
...data,
@ -257,6 +261,8 @@ export class TestInfoImpl implements TestInfo {
error,
};
this._onStepEnd(payload);
const errorForTrace = error ? { name: '', message: error.message || '', stack: error.stack } : undefined;
this._traceEvents.push(createAfterActionTraceEventForStep(stepId, serializeAttachments(this.attachments, initialAttachments), errorForTrace));
}
};
const parentStepList = parentStep ? parentStep.steps : this._steps;
@ -268,10 +274,13 @@ export class TestInfoImpl implements TestInfo {
testId: this._test.id,
stepId,
parentStepId: parentStep ? parentStep.stepId : undefined,
...data,
title: data.title,
category: data.category,
wallTime: data.wallTime,
location,
};
this._onStepBegin(payload);
this._traceEvents.push(createBeforeActionTraceEventForStep(stepId, parentStep?.stepId, data.apiName || data.title, data.params, data.wallTime, data.location ? [data.location] : []));
return step;
}
@ -380,5 +389,16 @@ export class TestInfoImpl implements TestInfo {
}
}
function serializeAttachments(attachments: TestInfo['attachments'], initialAttachments: Set<TestInfo['attachments'][0]>): trace.AfterActionTraceEvent['attachments'] {
return attachments.filter(a => !initialAttachments.has(a)).map(a => {
return {
name: a.name,
contentType: a.contentType,
path: a.path,
body: a.body?.toString('base64'),
};
});
}
class SkipError extends Error {
}

View File

@ -462,13 +462,12 @@ export class WorkerMain extends ProcessRunner {
});
}
const didRunTestError = await testInfo._runAndFailOnError(async () => await currentTestInstrumentation()?.didFinishTest(testInfo));
firstAfterHooksError = firstAfterHooksError || didRunTestError;
if (firstAfterHooksError)
step.complete({ error: firstAfterHooksError });
});
await testInfo._runAndFailOnError(async () => await currentTestInstrumentation()?.didFinishTest(testInfo));
this._currentTest = null;
setCurrentTestInfo(null);
this.dispatchEvent('testEnd', buildTestEndPayload(testInfo));

View File

@ -19,6 +19,7 @@ import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace';
export type ContextEntry = {
isPrimary: boolean;
traceUrl: string;
startTime: number;
endTime: number;
@ -47,6 +48,7 @@ export type PageEntry = {
};
export function createEmptyContext(): ContextEntry {
return {
isPrimary: false,
traceUrl: '',
startTime: Number.MAX_SAFE_INTEGER,
endTime: 0,

View File

@ -0,0 +1,27 @@
/**
* 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.
*/
type Progress = (done: number, total: number) => void;
export function splitProgress(progress: Progress, weights: number[]): Progress[] {
const doneList = new Array(weights.length).fill(0);
return new Array(weights.length).fill(0).map((_, i) => {
return (done: number, total: number) => {
doneList[i] = done / total * weights[i] * 1000;
progress(doneList.reduce((a, b) => a + b, 0), 1000);
};
});
}

View File

@ -52,4 +52,8 @@ export class SnapshotStorage {
const snapshot = this._frameSnapshots.get(pageOrFrameId);
return snapshot?.renderers.find(r => r.snapshotName === snapshotName);
}
snapshotsForTest() {
return [...this._frameSnapshots.keys()];
}
}

View File

@ -15,9 +15,11 @@
*/
import { MultiMap } from './multimap';
import { splitProgress } from './progress';
import { unwrapPopoutUrl } from './snapshotRenderer';
import { SnapshotServer } from './snapshotServer';
import { TraceModel } from './traceModel';
import { FetchTraceModelBackend, ZipTraceModelBackend } from './traceModelBackends';
// @ts-ignore
declare const self: ServiceWorkerGlobalScope;
@ -40,7 +42,10 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
clientIdToTraceUrls.set(clientId, traceUrl);
const traceModel = new TraceModel();
try {
await traceModel.load(traceUrl, progress);
// Allow 10% to hop from sw to page.
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress);
await traceModel.load(backend, unzipProgress);
} catch (error: any) {
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.');

View File

@ -16,28 +16,19 @@
import type * as trace from '@trace/trace';
import type * as traceV3 from './versions/traceV3';
import { parseClientSideCallMetadata } from '@isomorphic/traceUtils';
import type zip from '@zip.js/zip.js';
// @ts-ignore
import zipImport from '@zip.js/zip.js/dist/zip-no-worker-inflate.min.js';
import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils';
import type { ContextEntry, PageEntry } from './entries';
import { createEmptyContext } from './entries';
import { SnapshotStorage } from './snapshotStorage';
const zipjs = zipImport as typeof zip;
type Progress = (done: number, total: number) => void;
const splitProgress = (progress: Progress, weights: number[]): Progress[] => {
const doneList = new Array(weights.length).fill(0);
return new Array(weights.length).fill(0).map((_, i) => {
return (done: number, total: number) => {
doneList[i] = done / total * weights[i] * 1000;
progress(doneList.reduce((a, b) => a + b, 0), 1000);
};
});
};
export interface TraceModelBackend {
entryNames(): Promise<string[]>;
hasEntry(entryName: string): Promise<boolean>;
readText(entryName: string): Promise<string | undefined>;
readBlob(entryName: string): Promise<Blob | undefined>;
isLive(): boolean;
traceURL(): string;
}
export class TraceModel {
contextEntries: ContextEntry[] = [];
pageEntries = new Map<string, PageEntry>();
@ -48,11 +39,8 @@ export class TraceModel {
constructor() {
}
async load(traceURL: string, progress: (done: number, total: number) => void) {
const isLive = traceURL.endsWith('json');
// Allow 10% to hop from sw to page.
const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]);
this._backend = isLive ? new FetchTraceModelBackend(traceURL) : new ZipTraceModelBackend(traceURL, fetchProgress);
async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) {
this._backend = backend;
const ordinals: string[] = [];
let hasSource = false;
@ -74,7 +62,7 @@ export class TraceModel {
for (const ordinal of ordinals) {
const contextEntry = createEmptyContext();
const actionMap = new Map<string, trace.ActionTraceEvent>();
contextEntry.traceUrl = traceURL;
contextEntry.traceUrl = backend.traceURL();
contextEntry.hasSource = hasSource;
const trace = await this._backend.readText(ordinal + '.trace') || '';
@ -88,7 +76,7 @@ export class TraceModel {
unzipProgress(++done, total);
contextEntry.actions = [...actionMap.values()].sort((a1, a2) => a1.startTime - a2.startTime);
if (!isLive) {
if (!backend.isLive()) {
for (const action of contextEntry.actions) {
if (!action.endTime && !action.error)
action.error = { name: 'Error', message: 'Timed out' };
@ -140,6 +128,7 @@ export class TraceModel {
switch (event.type) {
case 'context-options': {
this._version = event.version;
contextEntry.isPrimary = true;
contextEntry.browserName = event.browserName;
contextEntry.title = event.title;
contextEntry.platform = event.platform;
@ -310,108 +299,3 @@ export class TraceModel {
};
}
}
export interface TraceModelBackend {
entryNames(): Promise<string[]>;
hasEntry(entryName: string): Promise<boolean>;
readText(entryName: string): Promise<string | undefined>;
readBlob(entryName: string): Promise<Blob | undefined>;
}
class ZipTraceModelBackend implements TraceModelBackend {
private _zipReader: zip.ZipReader;
private _entriesPromise: Promise<Map<string, zip.Entry>>;
constructor(traceURL: string, progress: (done: number, total: number) => void) {
this._zipReader = new zipjs.ZipReader(
new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any),
{ useWebWorkers: false }) as zip.ZipReader;
this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => {
const map = new Map<string, zip.Entry>();
for (const entry of entries)
map.set(entry.filename, entry);
return map;
});
}
async entryNames(): Promise<string[]> {
const entries = await this._entriesPromise;
return [...entries.keys()];
}
async hasEntry(entryName: string): Promise<boolean> {
const entries = await this._entriesPromise;
return entries.has(entryName);
}
async readText(entryName: string): Promise<string | undefined> {
const entries = await this._entriesPromise;
const entry = entries.get(entryName);
if (!entry)
return;
const writer = new zipjs.TextWriter();
await entry.getData?.(writer);
return writer.getData();
}
async readBlob(entryName: string): Promise<Blob | undefined> {
const entries = await this._entriesPromise;
const entry = entries.get(entryName);
if (!entry)
return;
const writer = new zipjs.BlobWriter() as zip.BlobWriter;
await entry.getData!(writer);
return writer.getData();
}
}
class FetchTraceModelBackend implements TraceModelBackend {
private _entriesPromise: Promise<Map<string, string>>;
constructor(traceURL: string) {
this._entriesPromise = fetch('/trace/file?path=' + encodeURI(traceURL)).then(async response => {
const json = JSON.parse(await response.text());
const entries = new Map<string, string>();
for (const entry of json.entries)
entries.set(entry.name, entry.path);
return entries;
});
}
async entryNames(): Promise<string[]> {
const entries = await this._entriesPromise;
return [...entries.keys()];
}
async hasEntry(entryName: string): Promise<boolean> {
const entries = await this._entriesPromise;
return entries.has(entryName);
}
async readText(entryName: string): Promise<string | undefined> {
const response = await this._readEntry(entryName);
return response?.text();
}
async readBlob(entryName: string): Promise<Blob | undefined> {
const response = await this._readEntry(entryName);
return response?.blob();
}
private async _readEntry(entryName: string): Promise<Response | undefined> {
const entries = await this._entriesPromise;
const fileName = entries.get(entryName);
if (!fileName)
return;
return fetch('/trace/file?path=' + encodeURI(fileName));
}
}
function 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;
}

View File

@ -0,0 +1,141 @@
/**
* 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 type zip from '@zip.js/zip.js';
// @ts-ignore
import zipImport from '@zip.js/zip.js/dist/zip-no-worker-inflate.min.js';
import type { TraceModelBackend } from './traceModel';
const zipjs = zipImport as typeof zip;
type Progress = (done: number, total: number) => void;
export class ZipTraceModelBackend implements TraceModelBackend {
private _zipReader: zip.ZipReader;
private _entriesPromise: Promise<Map<string, zip.Entry>>;
private _traceURL: string;
constructor(traceURL: string, progress: Progress) {
this._traceURL = traceURL;
this._zipReader = new zipjs.ZipReader(
new zipjs.HttpReader(formatUrl(traceURL), { mode: 'cors', preventHeadRequest: true } as any),
{ useWebWorkers: false }) as zip.ZipReader;
this._entriesPromise = this._zipReader.getEntries({ onprogress: progress }).then(entries => {
const map = new Map<string, zip.Entry>();
for (const entry of entries)
map.set(entry.filename, entry);
return map;
});
}
isLive() {
return false;
}
traceURL() {
return this._traceURL;
}
async entryNames(): Promise<string[]> {
const entries = await this._entriesPromise;
return [...entries.keys()];
}
async hasEntry(entryName: string): Promise<boolean> {
const entries = await this._entriesPromise;
return entries.has(entryName);
}
async readText(entryName: string): Promise<string | undefined> {
const entries = await this._entriesPromise;
const entry = entries.get(entryName);
if (!entry)
return;
const writer = new zipjs.TextWriter();
await entry.getData?.(writer);
return writer.getData();
}
async readBlob(entryName: string): Promise<Blob | undefined> {
const entries = await this._entriesPromise;
const entry = entries.get(entryName);
if (!entry)
return;
const writer = new zipjs.BlobWriter() as zip.BlobWriter;
await entry.getData!(writer);
return writer.getData();
}
}
export class FetchTraceModelBackend implements TraceModelBackend {
private _entriesPromise: Promise<Map<string, string>>;
private _traceURL: string;
constructor(traceURL: string) {
this._traceURL = traceURL;
this._entriesPromise = fetch('/trace/file?path=' + encodeURI(traceURL)).then(async response => {
const json = JSON.parse(await response.text());
const entries = new Map<string, string>();
for (const entry of json.entries)
entries.set(entry.name, entry.path);
return entries;
});
}
isLive() {
return true;
}
traceURL(): string {
return this._traceURL;
}
async entryNames(): Promise<string[]> {
const entries = await this._entriesPromise;
return [...entries.keys()];
}
async hasEntry(entryName: string): Promise<boolean> {
const entries = await this._entriesPromise;
return entries.has(entryName);
}
async readText(entryName: string): Promise<string | undefined> {
const response = await this._readEntry(entryName);
return response?.text();
}
async readBlob(entryName: string): Promise<Blob | undefined> {
const response = await this._readEntry(entryName);
return response?.blob();
}
private async _readEntry(entryName: string): Promise<Response | undefined> {
const entries = await this._entriesPromise;
const fileName = entries.get(entryName);
if (!fileName)
return;
return fetch('/trace/file?path=' + encodeURI(fileName));
}
}
function 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;
}

View File

@ -16,12 +16,13 @@
import type { ActionTraceEvent } from '@trace/trace';
import { msToString } from '@web/uiUtils';
import { ListView } from '@web/components/listView';
import * as React from 'react';
import './actionList.css';
import * as modelUtil from './modelUtil';
import { asLocator } from '@isomorphic/locatorGenerators';
import type { Language } from '@isomorphic/locatorGenerators';
import type { TreeState } from '@web/components/treeView';
import { TreeView } from '@web/components/treeView';
export interface ActionListProps {
actions: ActionTraceEvent[],
@ -32,25 +33,60 @@ export interface ActionListProps {
revealConsole: () => void,
}
const ActionListView = ListView<ActionTraceEvent>;
type ActionTreeItem = {
id: string;
children: ActionTreeItem[];
parent: ActionTreeItem | undefined;
action?: ActionTraceEvent;
};
const ActionTreeView = TreeView<ActionTreeItem>;
export const ActionList: React.FC<ActionListProps> = ({
actions = [],
actions,
selectedAction,
sdkLanguage,
onSelected = () => {},
onHighlighted = () => {},
revealConsole = () => {},
onSelected,
onHighlighted,
revealConsole,
}) => {
return <ActionListView
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const { rootItem, itemMap } = React.useMemo(() => {
const itemMap = new Map<string, ActionTreeItem>();
for (const action of actions) {
itemMap.set(action.callId, {
id: action.callId,
parent: undefined,
children: [],
action,
});
}
const rootItem: ActionTreeItem = { id: '', parent: undefined, children: [] };
for (const item of itemMap.values()) {
const parent = item.action!.parentId ? itemMap.get(item.action!.parentId) || rootItem : rootItem;
parent.children.push(item);
item.parent = parent;
}
return { rootItem, itemMap };
}, [actions]);
const { selectedItem } = React.useMemo(() => {
const selectedItem = selectedAction ? itemMap.get(selectedAction.callId) : undefined;
return { selectedItem };
}, [itemMap, selectedAction]);
return <ActionTreeView
dataTestId='action-list'
items={actions}
id={action => action.callId}
selectedItem={selectedAction}
onSelected={onSelected}
onHighlighted={onHighlighted}
isError={action => !!action.error?.message}
render={action => renderAction(action, sdkLanguage, revealConsole)}
rootItem={rootItem}
treeState={treeState}
setTreeState={setTreeState}
selectedItem={selectedItem}
onSelected={item => onSelected(item.action!)}
onHighlighted={item => onHighlighted(item?.action)}
isError={item => !!item.action?.error?.message}
render={item => renderAction(item.action!, sdkLanguage, revealConsole)}
/>;
};

View File

@ -62,12 +62,11 @@ export class MultiTraceModel {
this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE);
this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE);
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
this.actions = ([] as ActionTraceEvent[]).concat(...contexts.map(c => c.actions));
this.actions = mergeActions(contexts);
this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events));
this.hasSource = contexts.some(c => c.hasSource);
this.events.sort((a1, a2) => a1.time - a2.time);
this.actions = dedupeAndSortActions(this.actions);
this.sources = collectSources(this.actions);
}
}
@ -84,41 +83,50 @@ function indexModel(context: ContextEntry) {
(event as any)[contextSymbol] = context;
}
function dedupeAndSortActions(actions: ActionTraceEvent[]) {
const callActions = actions.filter(a => a.callId.startsWith('call@'));
const expectActions = actions.filter(a => a.callId.startsWith('expect@'));
function mergeActions(contexts: ContextEntry[]) {
const map = new Map<number, ActionTraceEvent>();
// Call startTime/endTime are server-side times.
// Expect startTime/endTime are client-side times.
// If there are call times, adjust expect startTime/endTime to align with callTime.
if (callActions.length && expectActions.length) {
const offset = callActions[0].startTime - callActions[0].wallTime!;
for (const expectAction of expectActions) {
const duration = expectAction.endTime - expectAction.startTime;
expectAction.startTime = expectAction.wallTime! + offset;
expectAction.endTime = expectAction.startTime + duration;
}
}
const callActionsByKey = new Map<string, ActionTraceEvent>();
for (const action of callActions)
callActionsByKey.set(action.apiName + '@' + action.wallTime, action);
// Protocol call aka isPrimary contexts have startTime/endTime as server-side times.
// Step aka non-isPrimary contexts have startTime/endTime are client-side times.
// Adjust expect startTime/endTime on non-primary contexts to put them on a single timeline.
let offset = 0;
const primaryContexts = contexts.filter(context => context.isPrimary);
const nonPrimaryContexts = contexts.filter(context => !context.isPrimary);
const result = [...callActions];
for (const expectAction of expectActions) {
const callAction = callActionsByKey.get(expectAction.apiName + '@' + expectAction.wallTime);
if (callAction) {
if (expectAction.error)
callAction.error = expectAction.error;
if (expectAction.attachments)
callAction.attachments = expectAction.attachments;
continue;
}
result.push(expectAction);
for (const context of primaryContexts) {
for (const action of context.actions)
map.set(action.wallTime, action);
if (!offset && context.actions.length)
offset = context.actions[0].startTime - context.actions[0].wallTime;
}
result.sort((a1, a2) => (a1.wallTime - a2.wallTime));
for (const context of nonPrimaryContexts) {
for (const action of context.actions) {
if (offset) {
const duration = action.endTime - action.startTime;
if (action.startTime)
action.startTime = action.wallTime + offset;
if (action.endTime)
action.endTime = action.startTime + duration;
}
const existing = map.get(action.wallTime);
if (existing && existing.apiName === action.apiName) {
if (action.error)
existing.error = action.error;
if (action.attachments)
existing.attachments = action.attachments;
continue;
}
map.set(action.wallTime, action);
}
}
const result = [...map.values()];
result.sort((a1, a2) => a1.wallTime - a2.wallTime);
for (let i = 1; i < result.length; ++i)
(result[i] as any)[prevInListSymbol] = result[i - 1];
return result;
}

View File

@ -471,7 +471,7 @@ const TestList: React.FC<{
runningState.itemSelectedByUser = true;
setSelectedTreeItemId(treeItem.id);
}}
autoExpandDeep={!!filterText}
autoExpandDepth={filterText ? 5 : 1}
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
};

View File

@ -58,11 +58,12 @@ export type BeforeActionTraceEvent = {
apiName: string;
class: string;
method: string;
params: any;
params: Record<string, any>;
wallTime: number;
beforeSnapshot?: string;
stack?: StackFrame[];
pageId?: string;
parentId?: string;
};
export type InputActionTraceEvent = {

View File

@ -40,7 +40,7 @@ export type TreeViewProps<T> = {
dataTestId?: string,
treeState: TreeState,
setTreeState: (treeState: TreeState) => void,
autoExpandDeep?: boolean,
autoExpandDepth?: number,
};
const TreeListView = ListView<TreeItem>;
@ -58,13 +58,13 @@ export function TreeView<T extends TreeItem>({
setTreeState,
noItemsMessage,
dataTestId,
autoExpandDeep,
autoExpandDepth,
}: TreeViewProps<T>) {
const treeItems = React.useMemo(() => {
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
treeState.expandedItems.set(item.id, true);
return flattenTree<T>(rootItem, treeState.expandedItems, autoExpandDeep);
}, [rootItem, selectedItem, treeState, autoExpandDeep]);
return flattenTree<T>(rootItem, treeState.expandedItems, autoExpandDepth || 0);
}, [rootItem, selectedItem, treeState, autoExpandDepth]);
return <TreeListView
items={[...treeItems.keys()]}
@ -128,12 +128,12 @@ type TreeItemData = {
parent: TreeItem | null,
};
function flattenTree<T extends TreeItem>(rootItem: T, expandedItems: Map<string, boolean | undefined>, autoExpandDeep?: boolean): Map<T, TreeItemData> {
function flattenTree<T extends TreeItem>(rootItem: T, expandedItems: Map<string, boolean | undefined>, autoExpandDepth: number): Map<T, TreeItemData> {
const result = new Map<T, TreeItemData>();
const appendChildren = (parent: T, depth: number) => {
for (const item of parent.children as T[]) {
const expandState = expandedItems.get(item.id);
const autoExpandMatches = (autoExpandDeep || depth === 0) && result.size < 25 && expandState !== false;
const autoExpandMatches = autoExpandDepth > depth && result.size < 25 && expandState !== false;
const expanded = item.children.length ? expandState || autoExpandMatches : undefined;
result.set(item, { depth, expanded, parent: rootItem === parent ? null : parent });
if (expanded)

View File

@ -16,9 +16,12 @@
import type { Frame, Page } from 'playwright-core';
import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile';
import type { TraceModelBackend } from '../../packages/trace-viewer/src/traceModel';
import type { StackFrame } from '../../packages/protocol/src/channels';
import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils';
import type { ActionTraceEvent, TraceEvent } from '../../packages/trace/src/trace';
import { TraceModel } from '../../packages/trace-viewer/src/traceModel';
import { MultiTraceModel } from '../../packages/trace-viewer/src/ui/modelUtil';
import type { ActionTraceEvent, EventTraceEvent, TraceEvent } from '@trace/trace';
export async function attachFrame(page: Page, frameId: string, url: string): Promise<Frame> {
const handle = await page.evaluateHandle(async ({ frameId, url }) => {
@ -94,7 +97,7 @@ export function suppressCertificateWarning() {
};
}
export async function parseTrace(file: string): Promise<{ events: any[], resources: Map<string, Buffer>, actions: string[], stacks: Map<string, StackFrame[]> }> {
export async function parseTraceRaw(file: string): Promise<{ events: any[], resources: Map<string, Buffer>, actions: string[], stacks: Map<string, StackFrame[]> }> {
const zipFS = new ZipFile(file);
const resources = new Map<string, Buffer>();
for (const entry of await zipFS.entries())
@ -162,6 +165,21 @@ function eventsToActions(events: ActionTraceEvent[]): string[] {
.map(e => e.apiName);
}
export async function parseTrace(file: string): Promise<{ resources: Map<string, Buffer>, events: EventTraceEvent[], actions: ActionTraceEvent[], apiNames: string[], traceModel: TraceModel, model: MultiTraceModel }> {
const backend = new TraceBackend(file);
const traceModel = new TraceModel();
await traceModel.load(backend, () => {});
const model = new MultiTraceModel(traceModel.contextEntries);
return {
apiNames: model.actions.map(a => a.apiName),
resources: backend.entries,
actions: model.actions,
events: model.events,
model,
traceModel,
};
}
export async function parseHar(file: string): Promise<Map<string, Buffer>> {
const zipFS = new ZipFile(file);
const resources = new Map<string, Buffer>();
@ -187,3 +205,55 @@ const ansiRegex = new RegExp('[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(
export function stripAnsi(str: string): string {
return str.replace(ansiRegex, '');
}
class TraceBackend implements TraceModelBackend {
private _fileName: string;
private _entriesPromise: Promise<Map<string, Buffer>>;
readonly entries = new Map<string, Buffer>();
constructor(fileName: string) {
this._fileName = fileName;
this._entriesPromise = this._readEntries();
}
private async _readEntries(): Promise<Map<string, Buffer>> {
const zipFS = new ZipFile(this._fileName);
for (const entry of await zipFS.entries())
this.entries.set(entry, await zipFS.read(entry));
zipFS.close();
return this.entries;
}
isLive() {
return false;
}
traceURL() {
return 'file://' + this._fileName;
}
async entryNames(): Promise<string[]> {
const entries = await this._entriesPromise;
return [...entries.keys()];
}
async hasEntry(entryName: string): Promise<boolean> {
const entries = await this._entriesPromise;
return entries.has(entryName);
}
async readText(entryName: string): Promise<string | undefined> {
const entries = await this._entriesPromise;
const entry = entries.get(entryName);
if (!entry)
return;
return entry.toString();
}
async readBlob(entryName: string) {
const entries = await this._entriesPromise;
const entry = entries.get(entryName);
return entry as any;
}
}

View File

@ -18,7 +18,7 @@ import fs from 'fs';
import { jpegjs } from 'playwright-core/lib/utilsBundle';
import path from 'path';
import { browserTest, contextTest as test, expect } from '../config/browserTest';
import { parseTrace } from '../config/utils';
import { parseTraceRaw } from '../config/utils';
import type { StackFrame } from '@protocol/channels';
import type { ActionTraceEvent } from '../../packages/trace/src/trace';
@ -36,7 +36,7 @@ test('should collect trace with resources, but no js', async ({ context, page, s
await page.close();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events, actions } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
expect(events[0].type).toBe('context-options');
expect(actions).toEqual([
'page.goto',
@ -77,7 +77,7 @@ test('should use the correct apiName for event driven callbacks', async ({ conte
await page.evaluate(() => alert('yo'));
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events, actions } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
expect(events[0].type).toBe('context-options');
expect(actions).toEqual([
'page.route',
@ -99,7 +99,7 @@ test('should not collect snapshots by default', async ({ context, page, server }
await page.close();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy();
expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy();
});
@ -111,7 +111,7 @@ test('should not include buffers in the trace', async ({ context, page, server,
await page.goto(server.PREFIX + '/empty.html');
await page.screenshot();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const screenshotEvent = events.find(e => e.type === 'action' && e.apiName === 'page.screenshot');
expect(screenshotEvent.beforeSnapshot).toBeTruthy();
expect(screenshotEvent.afterSnapshot).toBeTruthy();
@ -126,7 +126,7 @@ test('should exclude internal pages', async ({ browserName, context, page, serve
await page.close();
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const trace = await parseTrace(testInfo.outputPath('trace.zip'));
const trace = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const pageIds = new Set();
trace.events.forEach(e => {
const pageId = e.pageId;
@ -140,7 +140,7 @@ test('should include context API requests', async ({ browserName, context, page,
await context.tracing.start({ snapshots: true });
await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } });
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const postEvent = events.find(e => e.apiName === 'apiRequestContext.post');
expect(postEvent).toBeTruthy();
const harEntry = events.find(e => e.type === 'resource-snapshot');
@ -162,7 +162,7 @@ test('should collect two traces', async ({ context, page, server }, testInfo) =>
await context.tracing.stop({ path: testInfo.outputPath('trace2.zip') });
{
const { events, actions } = await parseTrace(testInfo.outputPath('trace1.zip'));
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
expect(events[0].type).toBe('context-options');
expect(actions).toEqual([
'page.goto',
@ -172,7 +172,7 @@ test('should collect two traces', async ({ context, page, server }, testInfo) =>
}
{
const { events, actions } = await parseTrace(testInfo.outputPath('trace2.zip'));
const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
expect(events[0].type).toBe('context-options');
expect(actions).toEqual([
'page.dblclick',
@ -208,7 +208,7 @@ test('should respect tracesDir and name', async ({ browserType, server }, testIn
}
{
const { resources, actions } = await parseTrace(testInfo.outputPath('trace1.zip'));
const { resources, actions } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
expect(actions).toEqual(['page.goto']);
expect(resourceNames(resources)).toEqual([
'resources/XXX.css',
@ -220,7 +220,7 @@ test('should respect tracesDir and name', async ({ browserType, server }, testIn
}
{
const { resources, actions } = await parseTrace(testInfo.outputPath('trace2.zip'));
const { resources, actions } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
expect(actions).toEqual(['page.goto']);
expect(resourceNames(resources)).toEqual([
'resources/XXX.css',
@ -249,7 +249,7 @@ test('should not include trace resources from the provious chunks', async ({ con
await context.tracing.stopChunk({ path: testInfo.outputPath('trace2.zip') });
{
const { resources } = await parseTrace(testInfo.outputPath('trace1.zip'));
const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
const names = Array.from(resources.keys());
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
expect(names.filter(n => n.endsWith('.jpeg')).length).toBeGreaterThan(0);
@ -258,7 +258,7 @@ test('should not include trace resources from the provious chunks', async ({ con
}
{
const { resources } = await parseTrace(testInfo.outputPath('trace2.zip'));
const { resources } = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
const names = Array.from(resources.keys());
// 1 network resource should be preserved.
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
@ -276,7 +276,7 @@ test('should overwrite existing file', async ({ context, page, server }, testInf
const path = testInfo.outputPath('trace1.zip');
await context.tracing.stop({ path });
{
const { resources } = await parseTrace(path);
const { resources } = await parseTraceRaw(path);
const names = Array.from(resources.keys());
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
}
@ -285,7 +285,7 @@ test('should overwrite existing file', async ({ context, page, server }, testInf
await context.tracing.stop({ path });
{
const { resources } = await parseTrace(path);
const { resources } = await parseTraceRaw(path);
const names = Array.from(resources.keys());
expect(names.filter(n => n.endsWith('.html')).length).toBe(0);
}
@ -298,7 +298,7 @@ test('should collect sources', async ({ context, page, server }, testInfo) => {
await page.click('"Click"');
await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });
const { resources } = await parseTrace(testInfo.outputPath('trace1.zip'));
const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
const sourceNames = Array.from(resources.keys()).filter(k => k.endsWith('.txt'));
expect(sourceNames.length).toBe(1);
const sourceFile = resources.get(sourceNames[0]);
@ -312,7 +312,7 @@ test('should record network failures', async ({ context, page, server }, testInf
await page.goto(server.EMPTY_PAGE).catch(e => {});
await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });
const { events } = await parseTrace(testInfo.outputPath('trace1.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace1.zip'));
const requestEvent = events.find(e => e.type === 'resource-snapshot' && !!e.snapshot.response._failureText);
expect(requestEvent).toBeTruthy();
});
@ -370,7 +370,7 @@ for (const params of [
}
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
const { events, resources } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const frames = events.filter(e => e.type === 'screencast-frame');
// Check all frame sizes.
@ -403,7 +403,7 @@ test('should include interrupted actions', async ({ context, page, server }, tes
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
await context.close();
const { events } = await parseTrace(testInfo.outputPath('trace.zip'));
const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip'));
const clickEvent = events.find(e => e.apiName === 'page.click');
expect(clickEvent).toBeTruthy();
});
@ -441,7 +441,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI
await page.click('"Click"');
await context.tracing.stopChunk(); // Should stop without a path.
const trace1 = await parseTrace(testInfo.outputPath('trace.zip'));
const trace1 = await parseTraceRaw(testInfo.outputPath('trace.zip'));
expect(trace1.events[0].type).toBe('context-options');
expect(trace1.actions).toEqual([
'page.setContent',
@ -451,7 +451,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI
expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBeTruthy();
expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy();
const trace2 = await parseTrace(testInfo.outputPath('trace2.zip'));
const trace2 = await parseTraceRaw(testInfo.outputPath('trace2.zip'));
expect(trace2.events[0].type).toBe('context-options');
expect(trace2.actions).toEqual([
'page.hover',
@ -501,7 +501,7 @@ test('should ignore iframes in head', async ({ context, page, server }, testInfo
await page.click('button');
await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });
const trace = await parseTrace(testInfo.outputPath('trace.zip'));
const trace = await parseTraceRaw(testInfo.outputPath('trace.zip'));
expect(trace.actions).toEqual([
'page.click',
]);
@ -522,7 +522,7 @@ test('should hide internal stack frames', async ({ context, page }, testInfo) =>
const tracePath = testInfo.outputPath('trace.zip');
await context.tracing.stop({ path: tracePath });
const trace = await parseTrace(tracePath);
const trace = await parseTraceRaw(tracePath);
const actions = trace.events.filter(e => e.type === 'action' && !e.apiName.startsWith('tracing.'));
expect(actions).toHaveLength(4);
for (const action of actions)
@ -543,7 +543,7 @@ test('should hide internal stack frames in expect', async ({ context, page }, te
const tracePath = testInfo.outputPath('trace.zip');
await context.tracing.stop({ path: tracePath });
const trace = await parseTrace(tracePath);
const trace = await parseTraceRaw(tracePath);
const actions = trace.events.filter(e => e.type === 'action' && !e.apiName.startsWith('tracing.'));
expect(actions).toHaveLength(5);
for (const action of actions)
@ -557,7 +557,7 @@ test('should record global request trace', async ({ request, context, server },
const tracePath = testInfo.outputPath('trace.zip');
await (request as any)._tracing.stop({ path: tracePath });
const trace = await parseTrace(tracePath);
const trace = await parseTraceRaw(tracePath);
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
expect(actions).toHaveLength(1);
expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
@ -594,7 +594,7 @@ test('should store global request traces separately', async ({ request, server,
(request2 as any)._tracing.stop({ path: trace2Path })
]);
{
const trace = await parseTrace(tracePath);
const trace = await parseTraceRaw(tracePath);
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
expect(actions).toHaveLength(1);
expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
@ -603,7 +603,7 @@ test('should store global request traces separately', async ({ request, server,
}));
}
{
const trace = await parseTrace(trace2Path);
const trace = await parseTraceRaw(trace2Path);
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
expect(actions).toHaveLength(1);
expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
@ -623,7 +623,7 @@ test('should store postData for global request', async ({ request, server }, tes
const tracePath = testInfo.outputPath('trace.zip');
await (request as any)._tracing.stop({ path: tracePath });
const trace = await parseTrace(tracePath);
const trace = await parseTraceRaw(tracePath);
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
expect(actions).toHaveLength(1);
const req = actions[0].snapshot.request;

View File

@ -22,7 +22,7 @@ import { spawnSync } from 'child_process';
import { PNG, jpegjs } from 'playwright-core/lib/utilsBundle';
import { registry } from '../../packages/playwright-core/lib/server';
import { rewriteErrorMessage } from '../../packages/playwright-core/lib/utils/stackTrace';
import { parseTrace } from '../config/utils';
import { parseTraceRaw } from '../config/utils';
const ffmpeg = registry.findExecutable('ffmpeg')!.executablePath('javascript');
@ -773,7 +773,7 @@ it.describe('screencast', () => {
const videoFile = await page.video().path();
expectRedFrames(videoFile, size);
const { events, resources } = await parseTrace(traceFile);
const { events, resources } = await parseTraceRaw(traceFile);
const frame = events.filter(e => e.type === 'screencast-frame').pop();
const buffer = resources.get('resources/' + frame.sha1);
const image = jpegjs.decode(buffer);

View File

@ -144,22 +144,29 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline
expect(result.passed).toBe(2);
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip'));
expect(trace1.actions).toEqual([
expect(trace1.apiNames).toEqual([
'Before Hooks',
'browserType.launch',
'browserContext.newPage',
'page.setContent',
'page.click',
'After Hooks',
'tracing.stopChunk',
]);
expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBe(true);
expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0);
expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace-1.zip'))).toBe(false);
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip'));
expect(trace2.actions).toEqual([
expect(trace2.apiNames).toEqual([
'Before Hooks',
'expect.toBe',
'page.setContent',
'page.fill',
'locator.click',
'After Hooks',
'tracing.stopChunk',
]);
expect(trace2.events.some(e => e.type === 'frame-snapshot')).toBe(true);
expect(trace2.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0);
});
test('should work with manually closed pages', async ({ runInlineTest }) => {
@ -481,19 +488,19 @@ test('should reset tracing', async ({ runInlineTest }, testInfo) => {
expect(result.passed).toBe(2);
const trace1 = await parseTrace(traceFile1);
expect(trace1.actions).toEqual([
expect(trace1.apiNames).toEqual([
'page.setContent',
'page.click',
]);
expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBe(true);
expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0);
const trace2 = await parseTrace(traceFile2);
expect(trace2.actions).toEqual([
expect(trace2.apiNames).toEqual([
'page.setContent',
'page.fill',
'locator.click',
]);
expect(trace2.events.some(e => e.type === 'frame-snapshot')).toBe(true);
expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0);
});
test('should not delete others contexts', async ({ runInlineTest }) => {

View File

@ -87,11 +87,49 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => {
expect(result.failed).toBe(1);
// One trace file for request context and one for each APIRequestContext
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
expect(trace1.actions).toEqual(['browserContext.newPage', 'page.goto', 'apiRequestContext.get']);
expect(trace1.apiNames).toEqual([
'Before Hooks',
'apiRequest.newContext',
'tracing.start',
'browserType.launch',
'browser.newContext',
'tracing.start',
'browserContext.newPage',
'page.goto',
'apiRequestContext.get',
'After Hooks',
'browserContext.close',
'tracing.stopChunk',
'apiRequestContext.dispose',
]);
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.zip'));
expect(trace2.actions).toEqual(['apiRequestContext.get']);
expect(trace2.apiNames).toEqual([
'Before Hooks',
'apiRequest.newContext',
'tracing.start',
'apiRequestContext.get',
'After Hooks',
'tracing.stopChunk',
]);
const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip'));
expect(trace3.actions).toEqual(['browserContext.newPage', 'page.goto', 'apiRequestContext.get', 'expect.toBe']);
expect(trace3.apiNames).toEqual([
'Before Hooks',
'tracing.startChunk',
'apiRequest.newContext',
'tracing.start',
'browser.newContext',
'tracing.start',
'browserContext.newPage',
'page.goto',
'apiRequestContext.get',
'expect.toBe',
'After Hooks',
'browserContext.close',
'tracing.stopChunk',
'apiRequestContext.dispose',
'browser.close',
'tracing.stopChunk',
]);
});
@ -275,7 +313,25 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'));
expect(trace1.actions).toEqual(['browserContext.newPage', 'page.goto', 'apiRequestContext.get']);
expect(trace1.apiNames).toEqual([
'Before Hooks',
'browserType.launch',
'browser.newContext',
'tracing.start',
'browserContext.newPage',
'page.goto',
'After Hooks',
'browserContext.close',
'afterAll hook',
'apiRequest.newContext',
'tracing.start',
'apiRequestContext.get',
'tracing.stopChunk',
'apiRequestContext.dispose',
'browser.close',
]);
const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e);
expect(error).toBeTruthy();
});
@ -409,7 +465,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa
}, { trace: 'retain-on-failure' });
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actions).toContain('page.goto');
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
});
@ -431,7 +487,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa
}, { trace: 'retain-on-failure' });
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actions).toContain('page.goto');
expect(trace.apiNames).toContain('page.goto');
expect(result.failed).toBe(1);
});
@ -451,6 +507,6 @@ test(`trace:retain-on-failure should create trace if request context is disposed
}, { trace: 'retain-on-failure' });
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
const trace = await parseTrace(tracePath);
expect(trace.actions).toContain('apiRequestContext.get');
expect(trace.apiNames).toContain('apiRequestContext.get');
expect(result.failed).toBe(1);
});

View File

@ -125,7 +125,9 @@ export const test = base
});
import { expect as baseExpect } from './stable-test-runner';
export const expect = baseExpect.configure({ timeout: 0 });
// Slow tests are 90s.
export const expect = baseExpect.configure({ timeout: process.env.CI ? 75000 : 25000 });
async function waitForLatch(latchFile: string) {
const fs = require('fs');

View File

@ -103,9 +103,12 @@ test('should update trace live', async ({ runUITest, server }) => {
).toHaveText('Two');
await expect(listItem).toHaveText([
/Before Hooks[\d.]+m?s/,
/browserContext.newPage[\d.]+m?s/,
/page.gotohttp:\/\/localhost:\d+\/one.html[\d.]+m?s/,
/page.gotohttp:\/\/localhost:\d+\/two.html[\d.]+m?s/
/page.gotohttp:\/\/localhost:\d+\/one.html/,
/page.gotohttp:\/\/localhost:\d+\/two.html/,
/After Hooks[\d.]+m?s/,
/browserContext.close[\d.]+m?s/,
]);
});

View File

@ -38,11 +38,15 @@ test('should merge trace events', async ({ runUITest, server }) => {
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/,
/Before Hooks[\d.]+m?s/,
/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/,
/After Hooks[\d.]+m?s/,
/browserContext.close[\d.]+m?s/,
]);
});
@ -64,9 +68,12 @@ test('should merge web assertion events', async ({ runUITest }, testInfo) => {
listItem,
'action list'
).toHaveText([
/browserContext\.newPage[\d.]+m?s/,
/page\.setContent[\d.]+m?s/,
/expect\.toBeVisiblelocator\('button'\)[\d.]+m?s/,
/Before Hooks[\d.]+m?s/,
/browserContext.newPage[\d.]+m?s/,
/page.setContent[\d.]+m?s/,
/expect.toBeVisiblelocator\('button'\)[\d.]+m?s/,
/After Hooks[\d.]+m?s/,
/browserContext.close[\d.]+m?s/,
]);
});
@ -84,13 +91,16 @@ test('should merge screenshot assertions', async ({ runUITest }, testInfo) => {
await page.getByText('trace test').dblclick();
const listItem = page.getByTestId('action-list').getByRole('listitem');
// TODO: fixme.
await expect(
listItem,
'action list'
).toHaveText([
/browserContext\.newPage[\d.]+m?s/,
/Before Hooks[\d.]+m?s/,
/browserContext.newPage[\d.]+m?s/,
/page\.setContent[\d.]+m?s/,
/expect\.toHaveScreenshot[\d.]+m?s/,
/After Hooks/,
]);
});
@ -105,6 +115,7 @@ test('should locate sync assertions in source', async ({ runUITest, server }) =>
});
await page.getByText('trace test').dblclick();
await page.getByText('expect.toBe').click();
await expect(
page.locator('.CodeMirror .source-line-running'),
@ -131,10 +142,13 @@ test('should show snapshots for sync assertions', async ({ runUITest, server })
listItem,
'action list'
).toHaveText([
/Before Hooks[\d.]+m?s/,
/browserContext\.newPage[\d.]+m?s/,
/page\.setContent[\d.]+m?s/,
/locator\.clickgetByRole\('button'\)[\d.]+m?s/,
/expect\.toBe[\d.]+m?s/,
/After Hooks[\d.]+m?s/,
/browserContext.close[\d.]+m?s/,
]);
await expect(

View File

@ -92,8 +92,8 @@ test('should print dependencies in ESM mode', async ({ runInlineTest, nodeVersio
const output = result.output;
const deps = JSON.parse(output.match(/###(.*)###/)![1]);
expect(deps).toEqual({
'a.test.ts': ['helperA.ts'],
'b.test.ts': ['helperA.ts', 'helperB.ts'],
'a.test.ts': ['helperA.ts', 'index.mjs'],
'b.test.ts': ['helperA.ts', 'helperB.ts', 'index.mjs'],
});
});

View File

@ -11,6 +11,7 @@
"useUnknownInCatchVariables": false,
"baseUrl": "..",
"paths": {
"@isomorphic/*": ["packages/playwright-core/src/utils/isomorphic/*"],
"@protocol/*": ["packages/protocol/src/*"],
"@recorder/*": ["packages/recorder/src/*"],
"@trace/*": ["packages/trace/src/*"],