chore: render test steps in the trace (#22837)
This commit is contained in:
parent
641e223ca8
commit
efad19b332
|
|
@ -57,15 +57,15 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
|
||||||
}
|
}
|
||||||
|
|
||||||
async _newContextForReuse(options: BrowserContextOptions = {}): Promise<BrowserContext> {
|
async _newContextForReuse(options: BrowserContextOptions = {}): Promise<BrowserContext> {
|
||||||
for (const context of this._contexts) {
|
return await this._wrapApiCall(async () => {
|
||||||
await this._wrapApiCall(async () => {
|
for (const context of this._contexts) {
|
||||||
await this._browserType._willCloseContext(context);
|
await this._browserType._willCloseContext(context);
|
||||||
}, true);
|
for (const page of context.pages())
|
||||||
for (const page of context.pages())
|
page._onClose();
|
||||||
page._onClose();
|
context._onClose();
|
||||||
context._onClose();
|
}
|
||||||
}
|
return await this._innerNewContext(options, true);
|
||||||
return await this._innerNewContext(options, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
|
async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise<BrowserContext> {
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,11 @@ import { maybeFindValidator, ValidationError, type ValidatorContext } from '../p
|
||||||
import { debugLogger } from '../common/debugLogger';
|
import { debugLogger } from '../common/debugLogger';
|
||||||
import type { ExpectZone, ParsedStackTrace } from '../utils/stackTrace';
|
import type { ExpectZone, ParsedStackTrace } from '../utils/stackTrace';
|
||||||
import { captureRawStack, captureLibraryStackTrace } from '../utils/stackTrace';
|
import { captureRawStack, captureLibraryStackTrace } from '../utils/stackTrace';
|
||||||
import { isString, isUnderTest } from '../utils';
|
import { isUnderTest } from '../utils';
|
||||||
import { zones } from '../utils/zones';
|
import { zones } from '../utils/zones';
|
||||||
import type { ClientInstrumentation } from './clientInstrumentation';
|
import type { ClientInstrumentation } from './clientInstrumentation';
|
||||||
import type { Connection } from './connection';
|
import type { Connection } from './connection';
|
||||||
import type { Logger } from './types';
|
import type { Logger } from './types';
|
||||||
import { asLocator } from '../utils/isomorphic/locatorGenerators';
|
|
||||||
|
|
||||||
type Listener = (...args: any[]) => void;
|
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;
|
const { stackTrace, csi, callCookie, wallTime } = apiZone.reported ? { csi: undefined, callCookie: undefined, stackTrace: null, wallTime: undefined } : apiZone;
|
||||||
apiZone.reported = true;
|
apiZone.reported = true;
|
||||||
if (csi && stackTrace && stackTrace.apiName)
|
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);
|
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);
|
return func(apiZone);
|
||||||
|
|
||||||
const stackTrace = captureLibraryStackTrace(stack);
|
const stackTrace = captureLibraryStackTrace(stack);
|
||||||
|
isInternal = isInternal || this._type === 'LocalUtils';
|
||||||
if (isInternal)
|
if (isInternal)
|
||||||
delete stackTrace.apiName;
|
delete stackTrace.apiName;
|
||||||
|
|
||||||
|
|
@ -227,29 +227,6 @@ function logApiCall(logger: Logger | undefined, message: string, isNested: boole
|
||||||
debugLogger.log('api', message);
|
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) {
|
function tChannelImplToWire(names: '*' | string[], arg: any, path: string, context: ValidatorContext) {
|
||||||
if (arg._object instanceof ChannelOwner && (names === '*' || names.includes(arg._object._type)))
|
if (arg._object instanceof ChannelOwner && (names === '*' || names.includes(arg._object._type)))
|
||||||
return { guid: arg._object._guid };
|
return { guid: arg._object._guid };
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export interface ClientInstrumentation {
|
||||||
addListener(listener: ClientInstrumentationListener): void;
|
addListener(listener: ClientInstrumentationListener): void;
|
||||||
removeListener(listener: ClientInstrumentationListener): void;
|
removeListener(listener: ClientInstrumentationListener): void;
|
||||||
removeAllListeners(): 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;
|
onApiCallEnd(userData: any, error?: Error): void;
|
||||||
onDidCreateBrowserContext(context: BrowserContext): Promise<void>;
|
onDidCreateBrowserContext(context: BrowserContext): Promise<void>;
|
||||||
onDidCreateRequestContext(context: APIRequestContext): Promise<void>;
|
onDidCreateRequestContext(context: APIRequestContext): Promise<void>;
|
||||||
|
|
@ -32,7 +32,7 @@ export interface ClientInstrumentation {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientInstrumentationListener {
|
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;
|
onApiCallEnd?(userData: any, error?: Error): void;
|
||||||
onDidCreateBrowserContext?(context: BrowserContext): Promise<void>;
|
onDidCreateBrowserContext?(context: BrowserContext): Promise<void>;
|
||||||
onDidCreateRequestContext?(context: APIRequestContext): Promise<void>;
|
onDidCreateRequestContext?(context: APIRequestContext): Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,4 @@ export * from './traceUtils';
|
||||||
export * from './userAgent';
|
export * from './userAgent';
|
||||||
export * from './zipFile';
|
export * from './zipFile';
|
||||||
export * from './zones';
|
export * from './zones';
|
||||||
|
export * from './isomorphic/locatorGenerators';
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,6 @@ const generators: Record<Language, LocatorFactory> = {
|
||||||
csharp: new CSharpLocatorFactory(),
|
csharp: new CSharpLocatorFactory(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isRegExp(obj: any): obj is RegExp {
|
function isRegExp(obj: any): obj is RegExp {
|
||||||
return obj instanceof RegExp;
|
return obj instanceof RegExp;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
return {
|
||||||
type: 'before',
|
type: 'before',
|
||||||
callId,
|
callId,
|
||||||
|
parentId,
|
||||||
wallTime,
|
wallTime,
|
||||||
startTime: monotonicTime(),
|
startTime: monotonicTime(),
|
||||||
class: 'Test',
|
class: 'Test',
|
||||||
method: 'step',
|
method: 'step',
|
||||||
apiName,
|
apiName,
|
||||||
params: { expected: generatePreview(expected) },
|
params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])),
|
||||||
stack,
|
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 {
|
return {
|
||||||
type: 'after',
|
type: 'after',
|
||||||
callId,
|
callId,
|
||||||
|
|
|
||||||
|
|
@ -187,9 +187,9 @@ const kPlaywrightCoveragePrefix = path.resolve(__dirname, '../../../../tests/con
|
||||||
export function belongsToNodeModules(file: string) {
|
export function belongsToNodeModules(file: string) {
|
||||||
if (file.includes(`${path.sep}node_modules${path.sep}`))
|
if (file.includes(`${path.sep}node_modules${path.sep}`))
|
||||||
return true;
|
return true;
|
||||||
if (file.startsWith(kPlaywrightInternalPrefix))
|
if (file.startsWith(kPlaywrightInternalPrefix) && file.endsWith('.js'))
|
||||||
return true;
|
return true;
|
||||||
if (file.startsWith(kPlaywrightCoveragePrefix))
|
if (file.startsWith(kPlaywrightCoveragePrefix) && file.endsWith('.js'))
|
||||||
return true;
|
return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
|
import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core';
|
||||||
import * as playwrightLibrary 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 { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test';
|
||||||
import type { TestInfoImpl } from './worker/testInfo';
|
import type { TestInfoImpl } from './worker/testInfo';
|
||||||
import { rootTestType } from './common/testType';
|
import { rootTestType } from './common/testType';
|
||||||
|
|
@ -269,14 +269,16 @@ const playwrightFixtures: Fixtures<TestFixtures, WorkerFixtures> = ({
|
||||||
let artifactsRecorder: ArtifactsRecorder | undefined;
|
let artifactsRecorder: ArtifactsRecorder | undefined;
|
||||||
|
|
||||||
const csiListener: ClientInstrumentationListener = {
|
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();
|
const testInfo = currentTestInfo();
|
||||||
if (!testInfo || apiCall.startsWith('expect.') || apiCall.includes('setTestIdAttribute'))
|
if (!testInfo || apiName.startsWith('expect.') || apiName.includes('setTestIdAttribute'))
|
||||||
return { userObject: null };
|
return { userObject: null };
|
||||||
const step = testInfo._addStep({
|
const step = testInfo._addStep({
|
||||||
location: stackTrace?.frames[0] as any,
|
location: stackTrace?.frames[0] as any,
|
||||||
category: 'pw:api',
|
category: 'pw:api',
|
||||||
title: apiCall,
|
title: renderApiCall(apiName, params),
|
||||||
|
apiName,
|
||||||
|
params,
|
||||||
wallTime,
|
wallTime,
|
||||||
laxParent: true,
|
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 const test = _baseTest.extend<TestFixtures, WorkerFixtures>(playwrightFixtures);
|
||||||
|
|
||||||
export default test;
|
export default test;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
captureRawStack,
|
captureRawStack,
|
||||||
createAfterActionTraceEventForExpect,
|
|
||||||
createBeforeActionTraceEventForExpect,
|
|
||||||
isString,
|
isString,
|
||||||
pollAgainstTimeout } from 'playwright-core/lib/utils';
|
pollAgainstTimeout } from 'playwright-core/lib/utils';
|
||||||
import type { ExpectZone } from 'playwright-core/lib/utils';
|
import type { ExpectZone } from 'playwright-core/lib/utils';
|
||||||
|
|
@ -48,7 +46,7 @@ import {
|
||||||
toPass
|
toPass
|
||||||
} from './matchers';
|
} from './matchers';
|
||||||
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot';
|
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 { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals';
|
||||||
import { filteredStackTrace, serializeError, stringifyStackFrames, trimLongString } from '../util';
|
import { filteredStackTrace, serializeError, stringifyStackFrames, trimLongString } from '../util';
|
||||||
import {
|
import {
|
||||||
|
|
@ -58,7 +56,6 @@ import {
|
||||||
printReceived,
|
printReceived,
|
||||||
} from '../common/expectBundle';
|
} from '../common/expectBundle';
|
||||||
import { zones } from 'playwright-core/lib/utils';
|
import { zones } from 'playwright-core/lib/utils';
|
||||||
import type { AfterActionTraceEvent } from '../../../trace/src/trace';
|
|
||||||
|
|
||||||
// from expect/build/types
|
// from expect/build/types
|
||||||
export type SyncExpectationResult = {
|
export type SyncExpectationResult = {
|
||||||
|
|
@ -79,8 +76,6 @@ export type SyncExpectationResult = {
|
||||||
// The replacement is compatible with pretty-format package.
|
// The replacement is compatible with pretty-format package.
|
||||||
const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&');
|
const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&');
|
||||||
|
|
||||||
let lastCallId = 0;
|
|
||||||
|
|
||||||
export const printReceivedStringContainExpectedSubstring = (
|
export const printReceivedStringContainExpectedSubstring = (
|
||||||
received: string,
|
received: string,
|
||||||
start: number,
|
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 defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`;
|
||||||
const wallTime = Date.now();
|
const wallTime = Date.now();
|
||||||
const initialAttachments = new Set(testInfo.attachments.slice());
|
|
||||||
const step = testInfo._addStep({
|
const step = testInfo._addStep({
|
||||||
location: stackFrames[0],
|
location: stackFrames[0],
|
||||||
category: 'expect',
|
category: 'expect',
|
||||||
title: trimLongString(customMessage || defaultTitle, 1024),
|
title: trimLongString(customMessage || defaultTitle, 1024),
|
||||||
|
params: args[0] ? { expected: args[0] } : undefined,
|
||||||
wallTime
|
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 reportStepError = (jestError: Error) => {
|
||||||
const message = jestError.message;
|
const message = jestError.message;
|
||||||
if (customMessage) {
|
if (customMessage) {
|
||||||
|
|
@ -291,10 +281,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializerError = serializeError(jestError);
|
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 });
|
step.complete({ error: serializerError });
|
||||||
if (this._info.isSoft)
|
if (this._info.isSoft)
|
||||||
testInfo._failWithError(serializerError, false /* isHardError */);
|
testInfo._failWithError(serializerError, false /* isHardError */);
|
||||||
|
|
@ -303,8 +289,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalizer = () => {
|
const finalizer = () => {
|
||||||
if (generateTraceEvent)
|
|
||||||
testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments)));
|
|
||||||
step.complete({});
|
step.complete({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -375,15 +359,4 @@ function computeArgsSuffix(matcherName: string, args: any[]) {
|
||||||
return value ? `(${value})` : '';
|
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);
|
expectLibrary.extend(customMatchers);
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,7 @@ export function fileIsModule(file: string): boolean {
|
||||||
return folderIsModule(folder);
|
return folderIsModule(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderIsModule(folder: string): boolean {
|
function folderIsModule(folder: string): boolean {
|
||||||
const packageJsonPath = getPackageJsonPath(folder);
|
const packageJsonPath = getPackageJsonPath(folder);
|
||||||
if (!packageJsonPath)
|
if (!packageJsonPath)
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test';
|
||||||
import type { StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc';
|
import type { StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc';
|
||||||
import type { TestCase } from '../common/test';
|
import type { TestCase } from '../common/test';
|
||||||
|
|
@ -36,6 +36,8 @@ interface TestStepInternal {
|
||||||
steps: TestStepInternal[];
|
steps: TestStepInternal[];
|
||||||
laxParent?: boolean;
|
laxParent?: boolean;
|
||||||
endWallTime?: number;
|
endWallTime?: number;
|
||||||
|
apiName?: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
error?: TestInfoError;
|
error?: TestInfoError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +230,8 @@ export class TestInfoImpl implements TestInfo {
|
||||||
isLaxParent = !!parentStep;
|
isLaxParent = !!parentStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialAttachments = new Set(this.attachments);
|
||||||
|
|
||||||
const step: TestStepInternal = {
|
const step: TestStepInternal = {
|
||||||
stepId,
|
stepId,
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -257,6 +261,8 @@ export class TestInfoImpl implements TestInfo {
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
this._onStepEnd(payload);
|
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;
|
const parentStepList = parentStep ? parentStep.steps : this._steps;
|
||||||
|
|
@ -268,10 +274,13 @@ export class TestInfoImpl implements TestInfo {
|
||||||
testId: this._test.id,
|
testId: this._test.id,
|
||||||
stepId,
|
stepId,
|
||||||
parentStepId: parentStep ? parentStep.stepId : undefined,
|
parentStepId: parentStep ? parentStep.stepId : undefined,
|
||||||
...data,
|
title: data.title,
|
||||||
|
category: data.category,
|
||||||
|
wallTime: data.wallTime,
|
||||||
location,
|
location,
|
||||||
};
|
};
|
||||||
this._onStepBegin(payload);
|
this._onStepBegin(payload);
|
||||||
|
this._traceEvents.push(createBeforeActionTraceEventForStep(stepId, parentStep?.stepId, data.apiName || data.title, data.params, data.wallTime, data.location ? [data.location] : []));
|
||||||
return step;
|
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 {
|
class SkipError extends Error {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,13 +462,12 @@ export class WorkerMain extends ProcessRunner {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const didRunTestError = await testInfo._runAndFailOnError(async () => await currentTestInstrumentation()?.didFinishTest(testInfo));
|
|
||||||
firstAfterHooksError = firstAfterHooksError || didRunTestError;
|
|
||||||
|
|
||||||
if (firstAfterHooksError)
|
if (firstAfterHooksError)
|
||||||
step.complete({ error: firstAfterHooksError });
|
step.complete({ error: firstAfterHooksError });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await testInfo._runAndFailOnError(async () => await currentTestInstrumentation()?.didFinishTest(testInfo));
|
||||||
|
|
||||||
this._currentTest = null;
|
this._currentTest = null;
|
||||||
setCurrentTestInfo(null);
|
setCurrentTestInfo(null);
|
||||||
this.dispatchEvent('testEnd', buildTestEndPayload(testInfo));
|
this.dispatchEvent('testEnd', buildTestEndPayload(testInfo));
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import type { ResourceSnapshot } from '@trace/snapshot';
|
||||||
import type * as trace from '@trace/trace';
|
import type * as trace from '@trace/trace';
|
||||||
|
|
||||||
export type ContextEntry = {
|
export type ContextEntry = {
|
||||||
|
isPrimary: boolean;
|
||||||
traceUrl: string;
|
traceUrl: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
|
|
@ -47,6 +48,7 @@ export type PageEntry = {
|
||||||
};
|
};
|
||||||
export function createEmptyContext(): ContextEntry {
|
export function createEmptyContext(): ContextEntry {
|
||||||
return {
|
return {
|
||||||
|
isPrimary: false,
|
||||||
traceUrl: '',
|
traceUrl: '',
|
||||||
startTime: Number.MAX_SAFE_INTEGER,
|
startTime: Number.MAX_SAFE_INTEGER,
|
||||||
endTime: 0,
|
endTime: 0,
|
||||||
|
|
|
||||||
27
packages/trace-viewer/src/progress.ts
Normal file
27
packages/trace-viewer/src/progress.ts
Normal 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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -52,4 +52,8 @@ export class SnapshotStorage {
|
||||||
const snapshot = this._frameSnapshots.get(pageOrFrameId);
|
const snapshot = this._frameSnapshots.get(pageOrFrameId);
|
||||||
return snapshot?.renderers.find(r => r.snapshotName === snapshotName);
|
return snapshot?.renderers.find(r => r.snapshotName === snapshotName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snapshotsForTest() {
|
||||||
|
return [...this._frameSnapshots.keys()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MultiMap } from './multimap';
|
import { MultiMap } from './multimap';
|
||||||
|
import { splitProgress } from './progress';
|
||||||
import { unwrapPopoutUrl } from './snapshotRenderer';
|
import { unwrapPopoutUrl } from './snapshotRenderer';
|
||||||
import { SnapshotServer } from './snapshotServer';
|
import { SnapshotServer } from './snapshotServer';
|
||||||
import { TraceModel } from './traceModel';
|
import { TraceModel } from './traceModel';
|
||||||
|
import { FetchTraceModelBackend, ZipTraceModelBackend } from './traceModelBackends';
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
declare const self: ServiceWorkerGlobalScope;
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
@ -40,7 +42,10 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI
|
||||||
clientIdToTraceUrls.set(clientId, traceUrl);
|
clientIdToTraceUrls.set(clientId, traceUrl);
|
||||||
const traceModel = new TraceModel();
|
const traceModel = new TraceModel();
|
||||||
try {
|
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) {
|
} catch (error: any) {
|
||||||
if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html'))
|
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.');
|
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.');
|
||||||
|
|
|
||||||
|
|
@ -16,28 +16,19 @@
|
||||||
|
|
||||||
import type * as trace from '@trace/trace';
|
import type * as trace from '@trace/trace';
|
||||||
import type * as traceV3 from './versions/traceV3';
|
import type * as traceV3 from './versions/traceV3';
|
||||||
import { parseClientSideCallMetadata } from '@isomorphic/traceUtils';
|
import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/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 type { ContextEntry, PageEntry } from './entries';
|
import type { ContextEntry, PageEntry } from './entries';
|
||||||
import { createEmptyContext } from './entries';
|
import { createEmptyContext } from './entries';
|
||||||
import { SnapshotStorage } from './snapshotStorage';
|
import { SnapshotStorage } from './snapshotStorage';
|
||||||
|
|
||||||
const zipjs = zipImport as typeof zip;
|
export interface TraceModelBackend {
|
||||||
|
entryNames(): Promise<string[]>;
|
||||||
type Progress = (done: number, total: number) => void;
|
hasEntry(entryName: string): Promise<boolean>;
|
||||||
|
readText(entryName: string): Promise<string | undefined>;
|
||||||
const splitProgress = (progress: Progress, weights: number[]): Progress[] => {
|
readBlob(entryName: string): Promise<Blob | undefined>;
|
||||||
const doneList = new Array(weights.length).fill(0);
|
isLive(): boolean;
|
||||||
return new Array(weights.length).fill(0).map((_, i) => {
|
traceURL(): string;
|
||||||
return (done: number, total: number) => {
|
}
|
||||||
doneList[i] = done / total * weights[i] * 1000;
|
|
||||||
progress(doneList.reduce((a, b) => a + b, 0), 1000);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export class TraceModel {
|
export class TraceModel {
|
||||||
contextEntries: ContextEntry[] = [];
|
contextEntries: ContextEntry[] = [];
|
||||||
pageEntries = new Map<string, PageEntry>();
|
pageEntries = new Map<string, PageEntry>();
|
||||||
|
|
@ -48,11 +39,8 @@ export class TraceModel {
|
||||||
constructor() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(traceURL: string, progress: (done: number, total: number) => void) {
|
async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) {
|
||||||
const isLive = traceURL.endsWith('json');
|
this._backend = backend;
|
||||||
// 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);
|
|
||||||
|
|
||||||
const ordinals: string[] = [];
|
const ordinals: string[] = [];
|
||||||
let hasSource = false;
|
let hasSource = false;
|
||||||
|
|
@ -74,7 +62,7 @@ export class TraceModel {
|
||||||
for (const ordinal of ordinals) {
|
for (const ordinal of ordinals) {
|
||||||
const contextEntry = createEmptyContext();
|
const contextEntry = createEmptyContext();
|
||||||
const actionMap = new Map<string, trace.ActionTraceEvent>();
|
const actionMap = new Map<string, trace.ActionTraceEvent>();
|
||||||
contextEntry.traceUrl = traceURL;
|
contextEntry.traceUrl = backend.traceURL();
|
||||||
contextEntry.hasSource = hasSource;
|
contextEntry.hasSource = hasSource;
|
||||||
|
|
||||||
const trace = await this._backend.readText(ordinal + '.trace') || '';
|
const trace = await this._backend.readText(ordinal + '.trace') || '';
|
||||||
|
|
@ -88,7 +76,7 @@ export class TraceModel {
|
||||||
unzipProgress(++done, total);
|
unzipProgress(++done, total);
|
||||||
|
|
||||||
contextEntry.actions = [...actionMap.values()].sort((a1, a2) => a1.startTime - a2.startTime);
|
contextEntry.actions = [...actionMap.values()].sort((a1, a2) => a1.startTime - a2.startTime);
|
||||||
if (!isLive) {
|
if (!backend.isLive()) {
|
||||||
for (const action of contextEntry.actions) {
|
for (const action of contextEntry.actions) {
|
||||||
if (!action.endTime && !action.error)
|
if (!action.endTime && !action.error)
|
||||||
action.error = { name: 'Error', message: 'Timed out' };
|
action.error = { name: 'Error', message: 'Timed out' };
|
||||||
|
|
@ -140,6 +128,7 @@ export class TraceModel {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'context-options': {
|
case 'context-options': {
|
||||||
this._version = event.version;
|
this._version = event.version;
|
||||||
|
contextEntry.isPrimary = true;
|
||||||
contextEntry.browserName = event.browserName;
|
contextEntry.browserName = event.browserName;
|
||||||
contextEntry.title = event.title;
|
contextEntry.title = event.title;
|
||||||
contextEntry.platform = event.platform;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
141
packages/trace-viewer/src/traceModelBackends.ts
Normal file
141
packages/trace-viewer/src/traceModelBackends.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -16,12 +16,13 @@
|
||||||
|
|
||||||
import type { ActionTraceEvent } from '@trace/trace';
|
import type { ActionTraceEvent } from '@trace/trace';
|
||||||
import { msToString } from '@web/uiUtils';
|
import { msToString } from '@web/uiUtils';
|
||||||
import { ListView } from '@web/components/listView';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './actionList.css';
|
import './actionList.css';
|
||||||
import * as modelUtil from './modelUtil';
|
import * as modelUtil from './modelUtil';
|
||||||
import { asLocator } from '@isomorphic/locatorGenerators';
|
import { asLocator } from '@isomorphic/locatorGenerators';
|
||||||
import type { Language } 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 {
|
export interface ActionListProps {
|
||||||
actions: ActionTraceEvent[],
|
actions: ActionTraceEvent[],
|
||||||
|
|
@ -32,25 +33,60 @@ export interface ActionListProps {
|
||||||
revealConsole: () => void,
|
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> = ({
|
export const ActionList: React.FC<ActionListProps> = ({
|
||||||
actions = [],
|
actions,
|
||||||
selectedAction,
|
selectedAction,
|
||||||
sdkLanguage,
|
sdkLanguage,
|
||||||
onSelected = () => {},
|
onSelected,
|
||||||
onHighlighted = () => {},
|
onHighlighted,
|
||||||
revealConsole = () => {},
|
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'
|
dataTestId='action-list'
|
||||||
items={actions}
|
rootItem={rootItem}
|
||||||
id={action => action.callId}
|
treeState={treeState}
|
||||||
selectedItem={selectedAction}
|
setTreeState={setTreeState}
|
||||||
onSelected={onSelected}
|
selectedItem={selectedItem}
|
||||||
onHighlighted={onHighlighted}
|
onSelected={item => onSelected(item.action!)}
|
||||||
isError={action => !!action.error?.message}
|
onHighlighted={item => onHighlighted(item?.action)}
|
||||||
render={action => renderAction(action, sdkLanguage, revealConsole)}
|
isError={item => !!item.action?.error?.message}
|
||||||
|
render={item => renderAction(item.action!, sdkLanguage, revealConsole)}
|
||||||
/>;
|
/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events));
|
||||||
this.hasSource = contexts.some(c => c.hasSource);
|
this.hasSource = contexts.some(c => c.hasSource);
|
||||||
|
|
||||||
this.events.sort((a1, a2) => a1.time - a2.time);
|
this.events.sort((a1, a2) => a1.time - a2.time);
|
||||||
this.actions = dedupeAndSortActions(this.actions);
|
|
||||||
this.sources = collectSources(this.actions);
|
this.sources = collectSources(this.actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,41 +83,50 @@ function indexModel(context: ContextEntry) {
|
||||||
(event as any)[contextSymbol] = context;
|
(event as any)[contextSymbol] = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeAndSortActions(actions: ActionTraceEvent[]) {
|
function mergeActions(contexts: ContextEntry[]) {
|
||||||
const callActions = actions.filter(a => a.callId.startsWith('call@'));
|
const map = new Map<number, ActionTraceEvent>();
|
||||||
const expectActions = actions.filter(a => a.callId.startsWith('expect@'));
|
|
||||||
|
|
||||||
// Call startTime/endTime are server-side times.
|
// Protocol call aka isPrimary contexts have startTime/endTime as server-side times.
|
||||||
// Expect startTime/endTime are client-side times.
|
// Step aka non-isPrimary contexts have startTime/endTime are client-side times.
|
||||||
// If there are call times, adjust expect startTime/endTime to align with callTime.
|
// Adjust expect startTime/endTime on non-primary contexts to put them on a single timeline.
|
||||||
if (callActions.length && expectActions.length) {
|
let offset = 0;
|
||||||
const offset = callActions[0].startTime - callActions[0].wallTime!;
|
const primaryContexts = contexts.filter(context => context.isPrimary);
|
||||||
for (const expectAction of expectActions) {
|
const nonPrimaryContexts = contexts.filter(context => !context.isPrimary);
|
||||||
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);
|
|
||||||
|
|
||||||
const result = [...callActions];
|
for (const context of primaryContexts) {
|
||||||
for (const expectAction of expectActions) {
|
for (const action of context.actions)
|
||||||
const callAction = callActionsByKey.get(expectAction.apiName + '@' + expectAction.wallTime);
|
map.set(action.wallTime, action);
|
||||||
if (callAction) {
|
if (!offset && context.actions.length)
|
||||||
if (expectAction.error)
|
offset = context.actions[0].startTime - context.actions[0].wallTime;
|
||||||
callAction.error = expectAction.error;
|
|
||||||
if (expectAction.attachments)
|
|
||||||
callAction.attachments = expectAction.attachments;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.push(expectAction);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
for (let i = 1; i < result.length; ++i)
|
||||||
(result[i] as any)[prevInListSymbol] = result[i - 1];
|
(result[i] as any)[prevInListSymbol] = result[i - 1];
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,7 @@ const TestList: React.FC<{
|
||||||
runningState.itemSelectedByUser = true;
|
runningState.itemSelectedByUser = true;
|
||||||
setSelectedTreeItemId(treeItem.id);
|
setSelectedTreeItemId(treeItem.id);
|
||||||
}}
|
}}
|
||||||
autoExpandDeep={!!filterText}
|
autoExpandDepth={filterText ? 5 : 1}
|
||||||
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
|
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,12 @@ export type BeforeActionTraceEvent = {
|
||||||
apiName: string;
|
apiName: string;
|
||||||
class: string;
|
class: string;
|
||||||
method: string;
|
method: string;
|
||||||
params: any;
|
params: Record<string, any>;
|
||||||
wallTime: number;
|
wallTime: number;
|
||||||
beforeSnapshot?: string;
|
beforeSnapshot?: string;
|
||||||
stack?: StackFrame[];
|
stack?: StackFrame[];
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
|
parentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InputActionTraceEvent = {
|
export type InputActionTraceEvent = {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export type TreeViewProps<T> = {
|
||||||
dataTestId?: string,
|
dataTestId?: string,
|
||||||
treeState: TreeState,
|
treeState: TreeState,
|
||||||
setTreeState: (treeState: TreeState) => void,
|
setTreeState: (treeState: TreeState) => void,
|
||||||
autoExpandDeep?: boolean,
|
autoExpandDepth?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TreeListView = ListView<TreeItem>;
|
const TreeListView = ListView<TreeItem>;
|
||||||
|
|
@ -58,13 +58,13 @@ export function TreeView<T extends TreeItem>({
|
||||||
setTreeState,
|
setTreeState,
|
||||||
noItemsMessage,
|
noItemsMessage,
|
||||||
dataTestId,
|
dataTestId,
|
||||||
autoExpandDeep,
|
autoExpandDepth,
|
||||||
}: TreeViewProps<T>) {
|
}: TreeViewProps<T>) {
|
||||||
const treeItems = React.useMemo(() => {
|
const treeItems = React.useMemo(() => {
|
||||||
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
|
for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent)
|
||||||
treeState.expandedItems.set(item.id, true);
|
treeState.expandedItems.set(item.id, true);
|
||||||
return flattenTree<T>(rootItem, treeState.expandedItems, autoExpandDeep);
|
return flattenTree<T>(rootItem, treeState.expandedItems, autoExpandDepth || 0);
|
||||||
}, [rootItem, selectedItem, treeState, autoExpandDeep]);
|
}, [rootItem, selectedItem, treeState, autoExpandDepth]);
|
||||||
|
|
||||||
return <TreeListView
|
return <TreeListView
|
||||||
items={[...treeItems.keys()]}
|
items={[...treeItems.keys()]}
|
||||||
|
|
@ -128,12 +128,12 @@ type TreeItemData = {
|
||||||
parent: TreeItem | null,
|
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 result = new Map<T, TreeItemData>();
|
||||||
const appendChildren = (parent: T, depth: number) => {
|
const appendChildren = (parent: T, depth: number) => {
|
||||||
for (const item of parent.children as T[]) {
|
for (const item of parent.children as T[]) {
|
||||||
const expandState = expandedItems.get(item.id);
|
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;
|
const expanded = item.children.length ? expandState || autoExpandMatches : undefined;
|
||||||
result.set(item, { depth, expanded, parent: rootItem === parent ? null : parent });
|
result.set(item, { depth, expanded, parent: rootItem === parent ? null : parent });
|
||||||
if (expanded)
|
if (expanded)
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@
|
||||||
|
|
||||||
import type { Frame, Page } from 'playwright-core';
|
import type { Frame, Page } from 'playwright-core';
|
||||||
import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile';
|
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 type { StackFrame } from '../../packages/protocol/src/channels';
|
||||||
import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils';
|
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> {
|
export async function attachFrame(page: Page, frameId: string, url: string): Promise<Frame> {
|
||||||
const handle = await page.evaluateHandle(async ({ frameId, url }) => {
|
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 zipFS = new ZipFile(file);
|
||||||
const resources = new Map<string, Buffer>();
|
const resources = new Map<string, Buffer>();
|
||||||
for (const entry of await zipFS.entries())
|
for (const entry of await zipFS.entries())
|
||||||
|
|
@ -162,6 +165,21 @@ function eventsToActions(events: ActionTraceEvent[]): string[] {
|
||||||
.map(e => e.apiName);
|
.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>> {
|
export async function parseHar(file: string): Promise<Map<string, Buffer>> {
|
||||||
const zipFS = new ZipFile(file);
|
const zipFS = new ZipFile(file);
|
||||||
const resources = new Map<string, Buffer>();
|
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 {
|
export function stripAnsi(str: string): string {
|
||||||
return str.replace(ansiRegex, '');
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import fs from 'fs';
|
||||||
import { jpegjs } from 'playwright-core/lib/utilsBundle';
|
import { jpegjs } from 'playwright-core/lib/utilsBundle';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { browserTest, contextTest as test, expect } from '../config/browserTest';
|
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 { StackFrame } from '@protocol/channels';
|
||||||
import type { ActionTraceEvent } from '../../packages/trace/src/trace';
|
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 page.close();
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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(events[0].type).toBe('context-options');
|
||||||
expect(actions).toEqual([
|
expect(actions).toEqual([
|
||||||
'page.goto',
|
'page.goto',
|
||||||
|
|
@ -77,7 +77,7 @@ test('should use the correct apiName for event driven callbacks', async ({ conte
|
||||||
await page.evaluate(() => alert('yo'));
|
await page.evaluate(() => alert('yo'));
|
||||||
|
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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(events[0].type).toBe('context-options');
|
||||||
expect(actions).toEqual([
|
expect(actions).toEqual([
|
||||||
'page.route',
|
'page.route',
|
||||||
|
|
@ -99,7 +99,7 @@ test('should not collect snapshots by default', async ({ context, page, server }
|
||||||
await page.close();
|
await page.close();
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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 === 'frame-snapshot')).toBeFalsy();
|
||||||
expect(events.some(e => e.type === 'resource-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.goto(server.PREFIX + '/empty.html');
|
||||||
await page.screenshot();
|
await page.screenshot();
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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');
|
const screenshotEvent = events.find(e => e.type === 'action' && e.apiName === 'page.screenshot');
|
||||||
expect(screenshotEvent.beforeSnapshot).toBeTruthy();
|
expect(screenshotEvent.beforeSnapshot).toBeTruthy();
|
||||||
expect(screenshotEvent.afterSnapshot).toBeTruthy();
|
expect(screenshotEvent.afterSnapshot).toBeTruthy();
|
||||||
|
|
@ -126,7 +126,7 @@ test('should exclude internal pages', async ({ browserName, context, page, serve
|
||||||
await page.close();
|
await page.close();
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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();
|
const pageIds = new Set();
|
||||||
trace.events.forEach(e => {
|
trace.events.forEach(e => {
|
||||||
const pageId = e.pageId;
|
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 context.tracing.start({ snapshots: true });
|
||||||
await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } });
|
await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } });
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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');
|
const postEvent = events.find(e => e.apiName === 'apiRequestContext.post');
|
||||||
expect(postEvent).toBeTruthy();
|
expect(postEvent).toBeTruthy();
|
||||||
const harEntry = events.find(e => e.type === 'resource-snapshot');
|
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') });
|
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(events[0].type).toBe('context-options');
|
||||||
expect(actions).toEqual([
|
expect(actions).toEqual([
|
||||||
'page.goto',
|
'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(events[0].type).toBe('context-options');
|
||||||
expect(actions).toEqual([
|
expect(actions).toEqual([
|
||||||
'page.dblclick',
|
'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(actions).toEqual(['page.goto']);
|
||||||
expect(resourceNames(resources)).toEqual([
|
expect(resourceNames(resources)).toEqual([
|
||||||
'resources/XXX.css',
|
'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(actions).toEqual(['page.goto']);
|
||||||
expect(resourceNames(resources)).toEqual([
|
expect(resourceNames(resources)).toEqual([
|
||||||
'resources/XXX.css',
|
'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') });
|
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());
|
const names = Array.from(resources.keys());
|
||||||
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
|
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
|
||||||
expect(names.filter(n => n.endsWith('.jpeg')).length).toBeGreaterThan(0);
|
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());
|
const names = Array.from(resources.keys());
|
||||||
// 1 network resource should be preserved.
|
// 1 network resource should be preserved.
|
||||||
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
|
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');
|
const path = testInfo.outputPath('trace1.zip');
|
||||||
await context.tracing.stop({ path });
|
await context.tracing.stop({ path });
|
||||||
{
|
{
|
||||||
const { resources } = await parseTrace(path);
|
const { resources } = await parseTraceRaw(path);
|
||||||
const names = Array.from(resources.keys());
|
const names = Array.from(resources.keys());
|
||||||
expect(names.filter(n => n.endsWith('.html')).length).toBe(1);
|
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 });
|
await context.tracing.stop({ path });
|
||||||
|
|
||||||
{
|
{
|
||||||
const { resources } = await parseTrace(path);
|
const { resources } = await parseTraceRaw(path);
|
||||||
const names = Array.from(resources.keys());
|
const names = Array.from(resources.keys());
|
||||||
expect(names.filter(n => n.endsWith('.html')).length).toBe(0);
|
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 page.click('"Click"');
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });
|
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'));
|
const sourceNames = Array.from(resources.keys()).filter(k => k.endsWith('.txt'));
|
||||||
expect(sourceNames.length).toBe(1);
|
expect(sourceNames.length).toBe(1);
|
||||||
const sourceFile = resources.get(sourceNames[0]);
|
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 page.goto(server.EMPTY_PAGE).catch(e => {});
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') });
|
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);
|
const requestEvent = events.find(e => e.type === 'resource-snapshot' && !!e.snapshot.response._failureText);
|
||||||
expect(requestEvent).toBeTruthy();
|
expect(requestEvent).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
@ -370,7 +370,7 @@ for (const params of [
|
||||||
}
|
}
|
||||||
await context.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
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');
|
const frames = events.filter(e => e.type === 'screencast-frame');
|
||||||
|
|
||||||
// Check all frame sizes.
|
// 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.tracing.stop({ path: testInfo.outputPath('trace.zip') });
|
||||||
await context.close();
|
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');
|
const clickEvent = events.find(e => e.apiName === 'page.click');
|
||||||
expect(clickEvent).toBeTruthy();
|
expect(clickEvent).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
@ -441,7 +441,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI
|
||||||
await page.click('"Click"');
|
await page.click('"Click"');
|
||||||
await context.tracing.stopChunk(); // Should stop without a path.
|
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.events[0].type).toBe('context-options');
|
||||||
expect(trace1.actions).toEqual([
|
expect(trace1.actions).toEqual([
|
||||||
'page.setContent',
|
'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 === 'frame-snapshot')).toBeTruthy();
|
||||||
expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).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.events[0].type).toBe('context-options');
|
||||||
expect(trace2.actions).toEqual([
|
expect(trace2.actions).toEqual([
|
||||||
'page.hover',
|
'page.hover',
|
||||||
|
|
@ -501,7 +501,7 @@ test('should ignore iframes in head', async ({ context, page, server }, testInfo
|
||||||
await page.click('button');
|
await page.click('button');
|
||||||
await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') });
|
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([
|
expect(trace.actions).toEqual([
|
||||||
'page.click',
|
'page.click',
|
||||||
]);
|
]);
|
||||||
|
|
@ -522,7 +522,7 @@ test('should hide internal stack frames', async ({ context, page }, testInfo) =>
|
||||||
const tracePath = testInfo.outputPath('trace.zip');
|
const tracePath = testInfo.outputPath('trace.zip');
|
||||||
await context.tracing.stop({ path: tracePath });
|
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.'));
|
const actions = trace.events.filter(e => e.type === 'action' && !e.apiName.startsWith('tracing.'));
|
||||||
expect(actions).toHaveLength(4);
|
expect(actions).toHaveLength(4);
|
||||||
for (const action of actions)
|
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');
|
const tracePath = testInfo.outputPath('trace.zip');
|
||||||
await context.tracing.stop({ path: tracePath });
|
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.'));
|
const actions = trace.events.filter(e => e.type === 'action' && !e.apiName.startsWith('tracing.'));
|
||||||
expect(actions).toHaveLength(5);
|
expect(actions).toHaveLength(5);
|
||||||
for (const action of actions)
|
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');
|
const tracePath = testInfo.outputPath('trace.zip');
|
||||||
await (request as any)._tracing.stop({ path: tracePath });
|
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');
|
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
|
||||||
expect(actions).toHaveLength(1);
|
expect(actions).toHaveLength(1);
|
||||||
expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
|
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 })
|
(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');
|
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
|
||||||
expect(actions).toHaveLength(1);
|
expect(actions).toHaveLength(1);
|
||||||
expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
|
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');
|
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
|
||||||
expect(actions).toHaveLength(1);
|
expect(actions).toHaveLength(1);
|
||||||
expect(actions[0].snapshot.request).toEqual(expect.objectContaining({
|
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');
|
const tracePath = testInfo.outputPath('trace.zip');
|
||||||
await (request as any)._tracing.stop({ path: tracePath });
|
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');
|
const actions = trace.events.filter(e => e.type === 'resource-snapshot');
|
||||||
expect(actions).toHaveLength(1);
|
expect(actions).toHaveLength(1);
|
||||||
const req = actions[0].snapshot.request;
|
const req = actions[0].snapshot.request;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { spawnSync } from 'child_process';
|
||||||
import { PNG, jpegjs } from 'playwright-core/lib/utilsBundle';
|
import { PNG, jpegjs } from 'playwright-core/lib/utilsBundle';
|
||||||
import { registry } from '../../packages/playwright-core/lib/server';
|
import { registry } from '../../packages/playwright-core/lib/server';
|
||||||
import { rewriteErrorMessage } from '../../packages/playwright-core/lib/utils/stackTrace';
|
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');
|
const ffmpeg = registry.findExecutable('ffmpeg')!.executablePath('javascript');
|
||||||
|
|
||||||
|
|
@ -773,7 +773,7 @@ it.describe('screencast', () => {
|
||||||
const videoFile = await page.video().path();
|
const videoFile = await page.video().path();
|
||||||
expectRedFrames(videoFile, size);
|
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 frame = events.filter(e => e.type === 'screencast-frame').pop();
|
||||||
const buffer = resources.get('resources/' + frame.sha1);
|
const buffer = resources.get('resources/' + frame.sha1);
|
||||||
const image = jpegjs.decode(buffer);
|
const image = jpegjs.decode(buffer);
|
||||||
|
|
|
||||||
|
|
@ -144,22 +144,29 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline
|
||||||
expect(result.passed).toBe(2);
|
expect(result.passed).toBe(2);
|
||||||
|
|
||||||
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip'));
|
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',
|
'browserContext.newPage',
|
||||||
'page.setContent',
|
'page.setContent',
|
||||||
'page.click',
|
'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);
|
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'));
|
const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip'));
|
||||||
expect(trace2.actions).toEqual([
|
expect(trace2.apiNames).toEqual([
|
||||||
|
'Before Hooks',
|
||||||
'expect.toBe',
|
'expect.toBe',
|
||||||
'page.setContent',
|
'page.setContent',
|
||||||
'page.fill',
|
'page.fill',
|
||||||
'locator.click',
|
'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 }) => {
|
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);
|
expect(result.passed).toBe(2);
|
||||||
|
|
||||||
const trace1 = await parseTrace(traceFile1);
|
const trace1 = await parseTrace(traceFile1);
|
||||||
expect(trace1.actions).toEqual([
|
expect(trace1.apiNames).toEqual([
|
||||||
'page.setContent',
|
'page.setContent',
|
||||||
'page.click',
|
'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);
|
const trace2 = await parseTrace(traceFile2);
|
||||||
expect(trace2.actions).toEqual([
|
expect(trace2.apiNames).toEqual([
|
||||||
'page.setContent',
|
'page.setContent',
|
||||||
'page.fill',
|
'page.fill',
|
||||||
'locator.click',
|
'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 }) => {
|
test('should not delete others contexts', async ({ runInlineTest }) => {
|
||||||
|
|
|
||||||
|
|
@ -87,11 +87,49 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => {
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
// One trace file for request context and one for each APIRequestContext
|
// One trace file for request context and one for each APIRequestContext
|
||||||
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip'));
|
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'));
|
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'));
|
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.passed).toBe(1);
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip'));
|
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);
|
const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e);
|
||||||
expect(error).toBeTruthy();
|
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' });
|
}, { trace: 'retain-on-failure' });
|
||||||
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
|
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
|
||||||
const trace = await parseTrace(tracePath);
|
const trace = await parseTrace(tracePath);
|
||||||
expect(trace.actions).toContain('page.goto');
|
expect(trace.apiNames).toContain('page.goto');
|
||||||
expect(result.failed).toBe(1);
|
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' });
|
}, { trace: 'retain-on-failure' });
|
||||||
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
|
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
|
||||||
const trace = await parseTrace(tracePath);
|
const trace = await parseTrace(tracePath);
|
||||||
expect(trace.actions).toContain('page.goto');
|
expect(trace.apiNames).toContain('page.goto');
|
||||||
expect(result.failed).toBe(1);
|
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' });
|
}, { trace: 'retain-on-failure' });
|
||||||
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
|
const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip');
|
||||||
const trace = await parseTrace(tracePath);
|
const trace = await parseTrace(tracePath);
|
||||||
expect(trace.actions).toContain('apiRequestContext.get');
|
expect(trace.apiNames).toContain('apiRequestContext.get');
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,9 @@ export const test = base
|
||||||
});
|
});
|
||||||
|
|
||||||
import { expect as baseExpect } from './stable-test-runner';
|
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) {
|
async function waitForLatch(latchFile: string) {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,12 @@ test('should update trace live', async ({ runUITest, server }) => {
|
||||||
).toHaveText('Two');
|
).toHaveText('Two');
|
||||||
|
|
||||||
await expect(listItem).toHaveText([
|
await expect(listItem).toHaveText([
|
||||||
|
/Before Hooks[\d.]+m?s/,
|
||||||
/browserContext.newPage[\d.]+m?s/,
|
/browserContext.newPage[\d.]+m?s/,
|
||||||
/page.gotohttp:\/\/localhost:\d+\/one.html[\d.]+m?s/,
|
/page.gotohttp:\/\/localhost:\d+\/one.html/,
|
||||||
/page.gotohttp:\/\/localhost:\d+\/two.html[\d.]+m?s/
|
/page.gotohttp:\/\/localhost:\d+\/two.html/,
|
||||||
|
/After Hooks[\d.]+m?s/,
|
||||||
|
/browserContext.close[\d.]+m?s/,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,15 @@ test('should merge trace events', async ({ runUITest, server }) => {
|
||||||
listItem,
|
listItem,
|
||||||
'action list'
|
'action list'
|
||||||
).toHaveText([
|
).toHaveText([
|
||||||
/browserContext\.newPage[\d.]+m?s/,
|
/Before Hooks[\d.]+m?s/,
|
||||||
/page\.setContent[\d.]+m?s/,
|
/browserContext.newPage[\d.]+m?s/,
|
||||||
/expect\.toBe[\d.]+m?s/,
|
/page.setContent[\d.]+m?s/,
|
||||||
/locator\.clickgetByRole\('button'\)[\d.]+m?s/,
|
/expect.toBe[\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,
|
listItem,
|
||||||
'action list'
|
'action list'
|
||||||
).toHaveText([
|
).toHaveText([
|
||||||
/browserContext\.newPage[\d.]+m?s/,
|
/Before Hooks[\d.]+m?s/,
|
||||||
/page\.setContent[\d.]+m?s/,
|
/browserContext.newPage[\d.]+m?s/,
|
||||||
/expect\.toBeVisiblelocator\('button'\)[\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();
|
await page.getByText('trace test').dblclick();
|
||||||
|
|
||||||
const listItem = page.getByTestId('action-list').getByRole('listitem');
|
const listItem = page.getByTestId('action-list').getByRole('listitem');
|
||||||
|
// TODO: fixme.
|
||||||
await expect(
|
await expect(
|
||||||
listItem,
|
listItem,
|
||||||
'action list'
|
'action list'
|
||||||
).toHaveText([
|
).toHaveText([
|
||||||
/browserContext\.newPage[\d.]+m?s/,
|
/Before Hooks[\d.]+m?s/,
|
||||||
|
/browserContext.newPage[\d.]+m?s/,
|
||||||
/page\.setContent[\d.]+m?s/,
|
/page\.setContent[\d.]+m?s/,
|
||||||
/expect\.toHaveScreenshot[\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('trace test').dblclick();
|
||||||
|
await page.getByText('expect.toBe').click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('.CodeMirror .source-line-running'),
|
page.locator('.CodeMirror .source-line-running'),
|
||||||
|
|
@ -131,10 +142,13 @@ test('should show snapshots for sync assertions', async ({ runUITest, server })
|
||||||
listItem,
|
listItem,
|
||||||
'action list'
|
'action list'
|
||||||
).toHaveText([
|
).toHaveText([
|
||||||
|
/Before Hooks[\d.]+m?s/,
|
||||||
/browserContext\.newPage[\d.]+m?s/,
|
/browserContext\.newPage[\d.]+m?s/,
|
||||||
/page\.setContent[\d.]+m?s/,
|
/page\.setContent[\d.]+m?s/,
|
||||||
/locator\.clickgetByRole\('button'\)[\d.]+m?s/,
|
/locator\.clickgetByRole\('button'\)[\d.]+m?s/,
|
||||||
/expect\.toBe[\d.]+m?s/,
|
/expect\.toBe[\d.]+m?s/,
|
||||||
|
/After Hooks[\d.]+m?s/,
|
||||||
|
/browserContext.close[\d.]+m?s/,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,8 @@ test('should print dependencies in ESM mode', async ({ runInlineTest, nodeVersio
|
||||||
const output = result.output;
|
const output = result.output;
|
||||||
const deps = JSON.parse(output.match(/###(.*)###/)![1]);
|
const deps = JSON.parse(output.match(/###(.*)###/)![1]);
|
||||||
expect(deps).toEqual({
|
expect(deps).toEqual({
|
||||||
'a.test.ts': ['helperA.ts'],
|
'a.test.ts': ['helperA.ts', 'index.mjs'],
|
||||||
'b.test.ts': ['helperA.ts', 'helperB.ts'],
|
'b.test.ts': ['helperA.ts', 'helperB.ts', 'index.mjs'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"useUnknownInCatchVariables": false,
|
"useUnknownInCatchVariables": false,
|
||||||
"baseUrl": "..",
|
"baseUrl": "..",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@isomorphic/*": ["packages/playwright-core/src/utils/isomorphic/*"],
|
||||||
"@protocol/*": ["packages/protocol/src/*"],
|
"@protocol/*": ["packages/protocol/src/*"],
|
||||||
"@recorder/*": ["packages/recorder/src/*"],
|
"@recorder/*": ["packages/recorder/src/*"],
|
||||||
"@trace/*": ["packages/trace/src/*"],
|
"@trace/*": ["packages/trace/src/*"],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue