From efad19b33213b913e44b8d437904aae0b0540a09 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 5 May 2023 15:12:18 -0700 Subject: [PATCH] chore: render test steps in the trace (#22837) --- .../playwright-core/src/client/browser.ts | 16 +- .../src/client/channelOwner.ts | 29 +--- .../src/client/clientInstrumentation.ts | 4 +- packages/playwright-core/src/utils/index.ts | 1 + .../src/utils/isomorphic/locatorGenerators.ts | 2 +- .../playwright-core/src/utils/traceUtils.ts | 7 +- .../src/common/compilationCache.ts | 4 +- packages/playwright-test/src/index.ts | 34 ++++- .../playwright-test/src/matchers/expect.ts | 31 +--- packages/playwright-test/src/util.ts | 2 +- .../playwright-test/src/worker/testInfo.ts | 24 ++- .../playwright-test/src/worker/workerMain.ts | 5 +- packages/trace-viewer/src/entries.ts | 2 + packages/trace-viewer/src/progress.ts | 27 ++++ packages/trace-viewer/src/snapshotStorage.ts | 4 + packages/trace-viewer/src/sw.ts | 7 +- packages/trace-viewer/src/traceModel.ts | 144 ++---------------- .../trace-viewer/src/traceModelBackends.ts | 141 +++++++++++++++++ packages/trace-viewer/src/ui/actionList.tsx | 64 ++++++-- packages/trace-viewer/src/ui/modelUtil.ts | 70 +++++---- packages/trace-viewer/src/ui/uiModeView.tsx | 2 +- packages/trace/src/trace.ts | 3 +- packages/web/src/components/treeView.tsx | 12 +- tests/config/utils.ts | 74 ++++++++- tests/library/tracing.spec.ts | 56 +++---- tests/library/video.spec.ts | 4 +- .../playwright-test/playwright.reuse.spec.ts | 23 ++- .../playwright-test/playwright.trace.spec.ts | 70 ++++++++- tests/playwright-test/ui-mode-fixtures.ts | 4 +- .../ui-mode-test-progress.spec.ts | 7 +- tests/playwright-test/ui-mode-trace.spec.ts | 32 ++-- tests/playwright-test/watch.spec.ts | 4 +- tests/tsconfig.json | 1 + 33 files changed, 584 insertions(+), 326 deletions(-) create mode 100644 packages/trace-viewer/src/progress.ts create mode 100644 packages/trace-viewer/src/traceModelBackends.ts diff --git a/packages/playwright-core/src/client/browser.ts b/packages/playwright-core/src/client/browser.ts index 48c0cbb832..6dd0b43477 100644 --- a/packages/playwright-core/src/client/browser.ts +++ b/packages/playwright-core/src/client/browser.ts @@ -57,15 +57,15 @@ export class Browser extends ChannelOwner implements ap } async _newContextForReuse(options: BrowserContextOptions = {}): Promise { - for (const context of this._contexts) { - await this._wrapApiCall(async () => { + return await this._wrapApiCall(async () => { + for (const context of this._contexts) { await this._browserType._willCloseContext(context); - }, true); - for (const page of context.pages()) - page._onClose(); - context._onClose(); - } - return await this._innerNewContext(options, true); + for (const page of context.pages()) + page._onClose(); + context._onClose(); + } + return await this._innerNewContext(options, true); + }, true); } async _innerNewContext(options: BrowserContextOptions = {}, forReuse: boolean): Promise { diff --git a/packages/playwright-core/src/client/channelOwner.ts b/packages/playwright-core/src/client/channelOwner.ts index e1e70b5970..3c3ed64415 100644 --- a/packages/playwright-core/src/client/channelOwner.ts +++ b/packages/playwright-core/src/client/channelOwner.ts @@ -20,12 +20,11 @@ import { maybeFindValidator, ValidationError, type ValidatorContext } from '../p import { debugLogger } from '../common/debugLogger'; import type { ExpectZone, ParsedStackTrace } from '../utils/stackTrace'; import { captureRawStack, captureLibraryStackTrace } from '../utils/stackTrace'; -import { isString, isUnderTest } from '../utils'; +import { isUnderTest } from '../utils'; import { zones } from '../utils/zones'; import type { ClientInstrumentation } from './clientInstrumentation'; import type { Connection } from './connection'; import type { Logger } from './types'; -import { asLocator } from '../utils/isomorphic/locatorGenerators'; type Listener = (...args: any[]) => void; @@ -145,7 +144,7 @@ export abstract class ChannelOwner, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void; onApiCallEnd(userData: any, error?: Error): void; onDidCreateBrowserContext(context: BrowserContext): Promise; onDidCreateRequestContext(context: APIRequestContext): Promise; @@ -32,7 +32,7 @@ export interface ClientInstrumentation { } export interface ClientInstrumentationListener { - onApiCallBegin?(apiCall: string, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void; + onApiCallBegin?(apiCall: string, params: Record, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any): void; onApiCallEnd?(userData: any, error?: Error): void; onDidCreateBrowserContext?(context: BrowserContext): Promise; onDidCreateRequestContext?(context: APIRequestContext): Promise; diff --git a/packages/playwright-core/src/utils/index.ts b/packages/playwright-core/src/utils/index.ts index 28d55ce684..1956d38ddd 100644 --- a/packages/playwright-core/src/utils/index.ts +++ b/packages/playwright-core/src/utils/index.ts @@ -40,3 +40,4 @@ export * from './traceUtils'; export * from './userAgent'; export * from './zipFile'; export * from './zones'; +export * from './isomorphic/locatorGenerators'; diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index d310764dc8..7a26ed785c 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -520,6 +520,6 @@ const generators: Record = { csharp: new CSharpLocatorFactory(), }; -export function isRegExp(obj: any): obj is RegExp { +function isRegExp(obj: any): obj is RegExp { return obj instanceof RegExp; } diff --git a/packages/playwright-core/src/utils/traceUtils.ts b/packages/playwright-core/src/utils/traceUtils.ts index b53d530bd4..2f7cb1cda2 100644 --- a/packages/playwright-core/src/utils/traceUtils.ts +++ b/packages/playwright-core/src/utils/traceUtils.ts @@ -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 | undefined, wallTime: number, stack: StackFrame[]): BeforeActionTraceEvent { return { type: 'before', callId, + parentId, wallTime, startTime: monotonicTime(), class: 'Test', method: 'step', apiName, - params: { expected: generatePreview(expected) }, + params: Object.fromEntries(Object.entries(params || {}).map(([name, value]) => [name, generatePreview(value)])), stack, }; } -export function createAfterActionTraceEventForExpect(callId: string, attachments: AfterActionTraceEvent['attachments'], error?: SerializedError['error']): AfterActionTraceEvent { +export function createAfterActionTraceEventForStep(callId: string, attachments: AfterActionTraceEvent['attachments'], error?: SerializedError['error']): AfterActionTraceEvent { return { type: 'after', callId, diff --git a/packages/playwright-test/src/common/compilationCache.ts b/packages/playwright-test/src/common/compilationCache.ts index 9d8b4a1977..9f32bba821 100644 --- a/packages/playwright-test/src/common/compilationCache.ts +++ b/packages/playwright-test/src/common/compilationCache.ts @@ -187,9 +187,9 @@ const kPlaywrightCoveragePrefix = path.resolve(__dirname, '../../../../tests/con export function belongsToNodeModules(file: string) { if (file.includes(`${path.sep}node_modules${path.sep}`)) return true; - if (file.startsWith(kPlaywrightInternalPrefix)) + if (file.startsWith(kPlaywrightInternalPrefix) && file.endsWith('.js')) return true; - if (file.startsWith(kPlaywrightCoveragePrefix)) + if (file.startsWith(kPlaywrightCoveragePrefix) && file.endsWith('.js')) return true; return false; } diff --git a/packages/playwright-test/src/index.ts b/packages/playwright-test/src/index.ts index 22caeb71ce..0128ba2339 100644 --- a/packages/playwright-test/src/index.ts +++ b/packages/playwright-test/src/index.ts @@ -18,7 +18,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { APIRequestContext, BrowserContext, Browser, BrowserContextOptions, LaunchOptions, Page, Tracing, Video } from 'playwright-core'; import * as playwrightLibrary from 'playwright-core'; -import { createGuid, debugMode, addInternalStackPrefix, mergeTraceFiles, saveTraceFile, removeFolders } from 'playwright-core/lib/utils'; +import { createGuid, debugMode, addInternalStackPrefix, mergeTraceFiles, saveTraceFile, removeFolders, isString, asLocator } from 'playwright-core/lib/utils'; import type { Fixtures, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, ScreenshotMode, TestInfo, TestType, TraceMode, VideoMode } from '../types/test'; import type { TestInfoImpl } from './worker/testInfo'; import { rootTestType } from './common/testType'; @@ -269,14 +269,16 @@ const playwrightFixtures: Fixtures = ({ let artifactsRecorder: ArtifactsRecorder | undefined; const csiListener: ClientInstrumentationListener = { - onApiCallBegin: (apiCall: string, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any) => { + onApiCallBegin: (apiName: string, params: Record, stackTrace: ParsedStackTrace | null, wallTime: number, userData: any) => { const testInfo = currentTestInfo(); - if (!testInfo || apiCall.startsWith('expect.') || apiCall.includes('setTestIdAttribute')) + if (!testInfo || apiName.startsWith('expect.') || apiName.includes('setTestIdAttribute')) return { userObject: null }; const step = testInfo._addStep({ location: stackTrace?.frames[0] as any, category: 'pw:api', - title: apiCall, + title: renderApiCall(apiName, params), + apiName, + params, wallTime, laxParent: true, }); @@ -745,6 +747,30 @@ class ArtifactsRecorder { } } +const paramsToRender = ['url', 'selector', 'text', 'key']; + +function renderApiCall(apiName: string, params: any) { + const paramsArray = []; + if (params) { + for (const name of paramsToRender) { + if (!(name in params)) + continue; + let value; + if (name === 'selector' && isString(params[name]) && params[name].startsWith('internal:')) { + const getter = asLocator('javascript', params[name], false, true); + apiName = apiName.replace(/^locator\./, 'locator.' + getter + '.'); + apiName = apiName.replace(/^page\./, 'page.' + getter + '.'); + apiName = apiName.replace(/^frame\./, 'frame.' + getter + '.'); + } else { + value = params[name]; + paramsArray.push(value); + } + } + } + const paramsText = paramsArray.length ? '(' + paramsArray.join(', ') + ')' : ''; + return apiName + paramsText; +} + export const test = _baseTest.extend(playwrightFixtures); export default test; diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index a3dd1b57e6..4f7d0ca747 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -16,8 +16,6 @@ import { captureRawStack, - createAfterActionTraceEventForExpect, - createBeforeActionTraceEventForExpect, isString, pollAgainstTimeout } from 'playwright-core/lib/utils'; import type { ExpectZone } from 'playwright-core/lib/utils'; @@ -48,7 +46,7 @@ import { toPass } from './matchers'; import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; -import type { Expect, TestInfo } from '../../types/test'; +import type { Expect } from '../../types/test'; import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals'; import { filteredStackTrace, serializeError, stringifyStackFrames, trimLongString } from '../util'; import { @@ -58,7 +56,6 @@ import { printReceived, } from '../common/expectBundle'; import { zones } from 'playwright-core/lib/utils'; -import type { AfterActionTraceEvent } from '../../../trace/src/trace'; // from expect/build/types export type SyncExpectationResult = { @@ -79,8 +76,6 @@ export type SyncExpectationResult = { // The replacement is compatible with pretty-format package. const printSubstring = (val: string): string => val.replace(/"|\\/g, '\\$&'); -let lastCallId = 0; - export const printReceivedStringContainExpectedSubstring = ( received: string, start: number, @@ -254,19 +249,14 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { const defaultTitle = `expect${this._info.isPoll ? '.poll' : ''}${this._info.isSoft ? '.soft' : ''}${this._info.isNot ? '.not' : ''}.${matcherName}${argsSuffix}`; const wallTime = Date.now(); - const initialAttachments = new Set(testInfo.attachments.slice()); const step = testInfo._addStep({ location: stackFrames[0], category: 'expect', title: trimLongString(customMessage || defaultTitle, 1024), + params: args[0] ? { expected: args[0] } : undefined, wallTime }); - const generateTraceEvent = matcherName !== 'poll' && matcherName !== 'toPass'; - const callId = ++lastCallId; - if (generateTraceEvent) - testInfo._traceEvents.push(createBeforeActionTraceEventForExpect(`expect@${callId}`, defaultTitle, wallTime, args[0], stackFrames)); - const reportStepError = (jestError: Error) => { const message = jestError.message; if (customMessage) { @@ -291,10 +281,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { } const serializerError = serializeError(jestError); - if (generateTraceEvent) { - const error = { name: jestError.name, message: jestError.message, stack: jestError.stack }; - testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments), error)); - } step.complete({ error: serializerError }); if (this._info.isSoft) testInfo._failWithError(serializerError, false /* isHardError */); @@ -303,8 +289,6 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler { }; const finalizer = () => { - if (generateTraceEvent) - testInfo._traceEvents.push(createAfterActionTraceEventForExpect(`expect@${callId}`, serializeAttachments(testInfo.attachments, initialAttachments))); step.complete({}); }; @@ -375,15 +359,4 @@ function computeArgsSuffix(matcherName: string, args: any[]) { return value ? `(${value})` : ''; } -function serializeAttachments(attachments: TestInfo['attachments'], initialAttachments: Set): 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); diff --git a/packages/playwright-test/src/util.ts b/packages/playwright-test/src/util.ts index 6165b11345..b7b5d1c64e 100644 --- a/packages/playwright-test/src/util.ts +++ b/packages/playwright-test/src/util.ts @@ -287,7 +287,7 @@ export function fileIsModule(file: string): boolean { return folderIsModule(folder); } -export function folderIsModule(folder: string): boolean { +function folderIsModule(folder: string): boolean { const packageJsonPath = getPackageJsonPath(folder); if (!packageJsonPath) return false; diff --git a/packages/playwright-test/src/worker/testInfo.ts b/packages/playwright-test/src/worker/testInfo.ts index cfe538e582..e3c25a7a68 100644 --- a/packages/playwright-test/src/worker/testInfo.ts +++ b/packages/playwright-test/src/worker/testInfo.ts @@ -16,7 +16,7 @@ import fs from 'fs'; import path from 'path'; -import { captureRawStack, monotonicTime, zones } from 'playwright-core/lib/utils'; +import { captureRawStack, createAfterActionTraceEventForStep, createBeforeActionTraceEventForStep, monotonicTime, zones } from 'playwright-core/lib/utils'; import type { TestInfoError, TestInfo, TestStatus, FullProject, FullConfig } from '../../types/test'; import type { StepBeginPayload, StepEndPayload, WorkerInitParams } from '../common/ipc'; import type { TestCase } from '../common/test'; @@ -36,6 +36,8 @@ interface TestStepInternal { steps: TestStepInternal[]; laxParent?: boolean; endWallTime?: number; + apiName?: string; + params?: Record; error?: TestInfoError; } @@ -228,6 +230,8 @@ export class TestInfoImpl implements TestInfo { isLaxParent = !!parentStep; } + const initialAttachments = new Set(this.attachments); + const step: TestStepInternal = { stepId, ...data, @@ -257,6 +261,8 @@ export class TestInfoImpl implements TestInfo { error, }; this._onStepEnd(payload); + const errorForTrace = error ? { name: '', message: error.message || '', stack: error.stack } : undefined; + this._traceEvents.push(createAfterActionTraceEventForStep(stepId, serializeAttachments(this.attachments, initialAttachments), errorForTrace)); } }; const parentStepList = parentStep ? parentStep.steps : this._steps; @@ -268,10 +274,13 @@ export class TestInfoImpl implements TestInfo { testId: this._test.id, stepId, parentStepId: parentStep ? parentStep.stepId : undefined, - ...data, + title: data.title, + category: data.category, + wallTime: data.wallTime, location, }; this._onStepBegin(payload); + this._traceEvents.push(createBeforeActionTraceEventForStep(stepId, parentStep?.stepId, data.apiName || data.title, data.params, data.wallTime, data.location ? [data.location] : [])); return step; } @@ -380,5 +389,16 @@ export class TestInfoImpl implements TestInfo { } } +function serializeAttachments(attachments: TestInfo['attachments'], initialAttachments: Set): 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 { } diff --git a/packages/playwright-test/src/worker/workerMain.ts b/packages/playwright-test/src/worker/workerMain.ts index 4ec6a669af..6293ab1257 100644 --- a/packages/playwright-test/src/worker/workerMain.ts +++ b/packages/playwright-test/src/worker/workerMain.ts @@ -462,13 +462,12 @@ export class WorkerMain extends ProcessRunner { }); } - const didRunTestError = await testInfo._runAndFailOnError(async () => await currentTestInstrumentation()?.didFinishTest(testInfo)); - firstAfterHooksError = firstAfterHooksError || didRunTestError; - if (firstAfterHooksError) step.complete({ error: firstAfterHooksError }); }); + await testInfo._runAndFailOnError(async () => await currentTestInstrumentation()?.didFinishTest(testInfo)); + this._currentTest = null; setCurrentTestInfo(null); this.dispatchEvent('testEnd', buildTestEndPayload(testInfo)); diff --git a/packages/trace-viewer/src/entries.ts b/packages/trace-viewer/src/entries.ts index e74dfbffb3..e8c82f31a2 100644 --- a/packages/trace-viewer/src/entries.ts +++ b/packages/trace-viewer/src/entries.ts @@ -19,6 +19,7 @@ import type { ResourceSnapshot } from '@trace/snapshot'; import type * as trace from '@trace/trace'; export type ContextEntry = { + isPrimary: boolean; traceUrl: string; startTime: number; endTime: number; @@ -47,6 +48,7 @@ export type PageEntry = { }; export function createEmptyContext(): ContextEntry { return { + isPrimary: false, traceUrl: '', startTime: Number.MAX_SAFE_INTEGER, endTime: 0, diff --git a/packages/trace-viewer/src/progress.ts b/packages/trace-viewer/src/progress.ts new file mode 100644 index 0000000000..c230698159 --- /dev/null +++ b/packages/trace-viewer/src/progress.ts @@ -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); + }; + }); +} diff --git a/packages/trace-viewer/src/snapshotStorage.ts b/packages/trace-viewer/src/snapshotStorage.ts index ece694c429..a341ebf38d 100644 --- a/packages/trace-viewer/src/snapshotStorage.ts +++ b/packages/trace-viewer/src/snapshotStorage.ts @@ -52,4 +52,8 @@ export class SnapshotStorage { const snapshot = this._frameSnapshots.get(pageOrFrameId); return snapshot?.renderers.find(r => r.snapshotName === snapshotName); } + + snapshotsForTest() { + return [...this._frameSnapshots.keys()]; + } } diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index d5d651c0b4..59c1340c95 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -15,9 +15,11 @@ */ import { MultiMap } from './multimap'; +import { splitProgress } from './progress'; import { unwrapPopoutUrl } from './snapshotRenderer'; import { SnapshotServer } from './snapshotServer'; import { TraceModel } from './traceModel'; +import { FetchTraceModelBackend, ZipTraceModelBackend } from './traceModelBackends'; // @ts-ignore declare const self: ServiceWorkerGlobalScope; @@ -40,7 +42,10 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI clientIdToTraceUrls.set(clientId, traceUrl); const traceModel = new TraceModel(); try { - await traceModel.load(traceUrl, progress); + // Allow 10% to hop from sw to page. + const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]); + const backend = traceUrl.endsWith('json') ? new FetchTraceModelBackend(traceUrl) : new ZipTraceModelBackend(traceUrl, fetchProgress); + await traceModel.load(backend, unzipProgress); } catch (error: any) { if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html')) throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.'); diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 77ebf3ea75..3cd9bebaef 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -16,28 +16,19 @@ import type * as trace from '@trace/trace'; import type * as traceV3 from './versions/traceV3'; -import { parseClientSideCallMetadata } from '@isomorphic/traceUtils'; -import type zip from '@zip.js/zip.js'; -// @ts-ignore -import zipImport from '@zip.js/zip.js/dist/zip-no-worker-inflate.min.js'; +import { parseClientSideCallMetadata } from '../../../packages/playwright-core/src/utils/isomorphic/traceUtils'; import type { ContextEntry, PageEntry } from './entries'; import { createEmptyContext } from './entries'; import { SnapshotStorage } from './snapshotStorage'; -const zipjs = zipImport as typeof zip; - -type Progress = (done: number, total: number) => void; - -const splitProgress = (progress: Progress, weights: number[]): Progress[] => { - const doneList = new Array(weights.length).fill(0); - return new Array(weights.length).fill(0).map((_, i) => { - return (done: number, total: number) => { - doneList[i] = done / total * weights[i] * 1000; - progress(doneList.reduce((a, b) => a + b, 0), 1000); - }; - }); -}; - +export interface TraceModelBackend { + entryNames(): Promise; + hasEntry(entryName: string): Promise; + readText(entryName: string): Promise; + readBlob(entryName: string): Promise; + isLive(): boolean; + traceURL(): string; +} export class TraceModel { contextEntries: ContextEntry[] = []; pageEntries = new Map(); @@ -48,11 +39,8 @@ export class TraceModel { constructor() { } - async load(traceURL: string, progress: (done: number, total: number) => void) { - const isLive = traceURL.endsWith('json'); - // Allow 10% to hop from sw to page. - const [fetchProgress, unzipProgress] = splitProgress(progress, [0.5, 0.4, 0.1]); - this._backend = isLive ? new FetchTraceModelBackend(traceURL) : new ZipTraceModelBackend(traceURL, fetchProgress); + async load(backend: TraceModelBackend, unzipProgress: (done: number, total: number) => void) { + this._backend = backend; const ordinals: string[] = []; let hasSource = false; @@ -74,7 +62,7 @@ export class TraceModel { for (const ordinal of ordinals) { const contextEntry = createEmptyContext(); const actionMap = new Map(); - contextEntry.traceUrl = traceURL; + contextEntry.traceUrl = backend.traceURL(); contextEntry.hasSource = hasSource; const trace = await this._backend.readText(ordinal + '.trace') || ''; @@ -88,7 +76,7 @@ export class TraceModel { unzipProgress(++done, total); contextEntry.actions = [...actionMap.values()].sort((a1, a2) => a1.startTime - a2.startTime); - if (!isLive) { + if (!backend.isLive()) { for (const action of contextEntry.actions) { if (!action.endTime && !action.error) action.error = { name: 'Error', message: 'Timed out' }; @@ -140,6 +128,7 @@ export class TraceModel { switch (event.type) { case 'context-options': { this._version = event.version; + contextEntry.isPrimary = true; contextEntry.browserName = event.browserName; contextEntry.title = event.title; contextEntry.platform = event.platform; @@ -310,108 +299,3 @@ export class TraceModel { }; } } - -export interface TraceModelBackend { - entryNames(): Promise; - hasEntry(entryName: string): Promise; - readText(entryName: string): Promise; - readBlob(entryName: string): Promise; -} - -class ZipTraceModelBackend implements TraceModelBackend { - private _zipReader: zip.ZipReader; - private _entriesPromise: Promise>; - - 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(); - for (const entry of entries) - map.set(entry.filename, entry); - return map; - }); - } - - async entryNames(): Promise { - const entries = await this._entriesPromise; - return [...entries.keys()]; - } - - async hasEntry(entryName: string): Promise { - const entries = await this._entriesPromise; - return entries.has(entryName); - } - - async readText(entryName: string): Promise { - 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 { - 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>; - - 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(); - for (const entry of json.entries) - entries.set(entry.name, entry.path); - return entries; - }); - } - - async entryNames(): Promise { - const entries = await this._entriesPromise; - return [...entries.keys()]; - } - - async hasEntry(entryName: string): Promise { - const entries = await this._entriesPromise; - return entries.has(entryName); - } - - async readText(entryName: string): Promise { - const response = await this._readEntry(entryName); - return response?.text(); - } - - async readBlob(entryName: string): Promise { - const response = await this._readEntry(entryName); - return response?.blob(); - } - - private async _readEntry(entryName: string): Promise { - 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; -} diff --git a/packages/trace-viewer/src/traceModelBackends.ts b/packages/trace-viewer/src/traceModelBackends.ts new file mode 100644 index 0000000000..15faa55ac6 --- /dev/null +++ b/packages/trace-viewer/src/traceModelBackends.ts @@ -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>; + 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(); + for (const entry of entries) + map.set(entry.filename, entry); + return map; + }); + } + + isLive() { + return false; + } + + traceURL() { + return this._traceURL; + } + + async entryNames(): Promise { + const entries = await this._entriesPromise; + return [...entries.keys()]; + } + + async hasEntry(entryName: string): Promise { + const entries = await this._entriesPromise; + return entries.has(entryName); + } + + async readText(entryName: string): Promise { + 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 { + 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>; + 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(); + 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 { + const entries = await this._entriesPromise; + return [...entries.keys()]; + } + + async hasEntry(entryName: string): Promise { + const entries = await this._entriesPromise; + return entries.has(entryName); + } + + async readText(entryName: string): Promise { + const response = await this._readEntry(entryName); + return response?.text(); + } + + async readBlob(entryName: string): Promise { + const response = await this._readEntry(entryName); + return response?.blob(); + } + + private async _readEntry(entryName: string): Promise { + 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; +} diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index 00f0d2e8e3..22fe0e9eb4 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -16,12 +16,13 @@ import type { ActionTraceEvent } from '@trace/trace'; import { msToString } from '@web/uiUtils'; -import { ListView } from '@web/components/listView'; import * as React from 'react'; import './actionList.css'; import * as modelUtil from './modelUtil'; import { asLocator } from '@isomorphic/locatorGenerators'; import type { Language } from '@isomorphic/locatorGenerators'; +import type { TreeState } from '@web/components/treeView'; +import { TreeView } from '@web/components/treeView'; export interface ActionListProps { actions: ActionTraceEvent[], @@ -32,25 +33,60 @@ export interface ActionListProps { revealConsole: () => void, } -const ActionListView = ListView; +type ActionTreeItem = { + id: string; + children: ActionTreeItem[]; + parent: ActionTreeItem | undefined; + action?: ActionTraceEvent; +}; + +const ActionTreeView = TreeView; export const ActionList: React.FC = ({ - actions = [], + actions, selectedAction, sdkLanguage, - onSelected = () => {}, - onHighlighted = () => {}, - revealConsole = () => {}, + onSelected, + onHighlighted, + revealConsole, }) => { - return ({ expandedItems: new Map() }); + const { rootItem, itemMap } = React.useMemo(() => { + const itemMap = new Map(); + + 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 action.callId} - selectedItem={selectedAction} - onSelected={onSelected} - onHighlighted={onHighlighted} - isError={action => !!action.error?.message} - render={action => renderAction(action, sdkLanguage, revealConsole)} + rootItem={rootItem} + treeState={treeState} + setTreeState={setTreeState} + selectedItem={selectedItem} + onSelected={item => onSelected(item.action!)} + onHighlighted={item => onHighlighted(item?.action)} + isError={item => !!item.action?.error?.message} + render={item => renderAction(item.action!, sdkLanguage, revealConsole)} />; }; diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 470e647a4b..b6943fbcfc 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -62,12 +62,11 @@ export class MultiTraceModel { this.startTime = contexts.map(c => c.startTime).reduce((prev, cur) => Math.min(prev, cur), Number.MAX_VALUE); this.endTime = contexts.map(c => c.endTime).reduce((prev, cur) => Math.max(prev, cur), Number.MIN_VALUE); this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); - this.actions = ([] as ActionTraceEvent[]).concat(...contexts.map(c => c.actions)); + this.actions = mergeActions(contexts); this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events)); this.hasSource = contexts.some(c => c.hasSource); this.events.sort((a1, a2) => a1.time - a2.time); - this.actions = dedupeAndSortActions(this.actions); this.sources = collectSources(this.actions); } } @@ -84,41 +83,50 @@ function indexModel(context: ContextEntry) { (event as any)[contextSymbol] = context; } -function dedupeAndSortActions(actions: ActionTraceEvent[]) { - const callActions = actions.filter(a => a.callId.startsWith('call@')); - const expectActions = actions.filter(a => a.callId.startsWith('expect@')); +function mergeActions(contexts: ContextEntry[]) { + const map = new Map(); - // Call startTime/endTime are server-side times. - // Expect startTime/endTime are client-side times. - // If there are call times, adjust expect startTime/endTime to align with callTime. - if (callActions.length && expectActions.length) { - const offset = callActions[0].startTime - callActions[0].wallTime!; - for (const expectAction of expectActions) { - const duration = expectAction.endTime - expectAction.startTime; - expectAction.startTime = expectAction.wallTime! + offset; - expectAction.endTime = expectAction.startTime + duration; - } - } - const callActionsByKey = new Map(); - for (const action of callActions) - callActionsByKey.set(action.apiName + '@' + action.wallTime, action); + // Protocol call aka isPrimary contexts have startTime/endTime as server-side times. + // Step aka non-isPrimary contexts have startTime/endTime are client-side times. + // Adjust expect startTime/endTime on non-primary contexts to put them on a single timeline. + let offset = 0; + const primaryContexts = contexts.filter(context => context.isPrimary); + const nonPrimaryContexts = contexts.filter(context => !context.isPrimary); - const result = [...callActions]; - for (const expectAction of expectActions) { - const callAction = callActionsByKey.get(expectAction.apiName + '@' + expectAction.wallTime); - if (callAction) { - if (expectAction.error) - callAction.error = expectAction.error; - if (expectAction.attachments) - callAction.attachments = expectAction.attachments; - continue; - } - result.push(expectAction); + for (const context of primaryContexts) { + for (const action of context.actions) + map.set(action.wallTime, action); + if (!offset && context.actions.length) + offset = context.actions[0].startTime - context.actions[0].wallTime; } - result.sort((a1, a2) => (a1.wallTime - a2.wallTime)); + for (const context of nonPrimaryContexts) { + for (const action of context.actions) { + if (offset) { + const duration = action.endTime - action.startTime; + if (action.startTime) + action.startTime = action.wallTime + offset; + if (action.endTime) + action.endTime = action.startTime + duration; + } + + const existing = map.get(action.wallTime); + if (existing && existing.apiName === action.apiName) { + if (action.error) + existing.error = action.error; + if (action.attachments) + existing.attachments = action.attachments; + continue; + } + map.set(action.wallTime, action); + } + } + + const result = [...map.values()]; + result.sort((a1, a2) => a1.wallTime - a2.wallTime); for (let i = 1; i < result.length; ++i) (result[i] as any)[prevInListSymbol] = result[i - 1]; + return result; } diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 8e18a0ca3b..bc9f85bdc3 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -471,7 +471,7 @@ const TestList: React.FC<{ runningState.itemSelectedByUser = true; setSelectedTreeItemId(treeItem.id); }} - autoExpandDeep={!!filterText} + autoExpandDepth={filterText ? 5 : 1} noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; }; diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index 39bd6fb092..c70734d93c 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -58,11 +58,12 @@ export type BeforeActionTraceEvent = { apiName: string; class: string; method: string; - params: any; + params: Record; wallTime: number; beforeSnapshot?: string; stack?: StackFrame[]; pageId?: string; + parentId?: string; }; export type InputActionTraceEvent = { diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx index dd3f9fc4e4..56b09eac3f 100644 --- a/packages/web/src/components/treeView.tsx +++ b/packages/web/src/components/treeView.tsx @@ -40,7 +40,7 @@ export type TreeViewProps = { dataTestId?: string, treeState: TreeState, setTreeState: (treeState: TreeState) => void, - autoExpandDeep?: boolean, + autoExpandDepth?: number, }; const TreeListView = ListView; @@ -58,13 +58,13 @@ export function TreeView({ setTreeState, noItemsMessage, dataTestId, - autoExpandDeep, + autoExpandDepth, }: TreeViewProps) { const treeItems = React.useMemo(() => { for (let item: TreeItem | undefined = selectedItem?.parent; item; item = item.parent) treeState.expandedItems.set(item.id, true); - return flattenTree(rootItem, treeState.expandedItems, autoExpandDeep); - }, [rootItem, selectedItem, treeState, autoExpandDeep]); + return flattenTree(rootItem, treeState.expandedItems, autoExpandDepth || 0); + }, [rootItem, selectedItem, treeState, autoExpandDepth]); return (rootItem: T, expandedItems: Map, autoExpandDeep?: boolean): Map { +function flattenTree(rootItem: T, expandedItems: Map, autoExpandDepth: number): Map { const result = new Map(); const appendChildren = (parent: T, depth: number) => { for (const item of parent.children as T[]) { const expandState = expandedItems.get(item.id); - const autoExpandMatches = (autoExpandDeep || depth === 0) && result.size < 25 && expandState !== false; + const autoExpandMatches = autoExpandDepth > depth && result.size < 25 && expandState !== false; const expanded = item.children.length ? expandState || autoExpandMatches : undefined; result.set(item, { depth, expanded, parent: rootItem === parent ? null : parent }); if (expanded) diff --git a/tests/config/utils.ts b/tests/config/utils.ts index 181a46d809..0603b669ab 100644 --- a/tests/config/utils.ts +++ b/tests/config/utils.ts @@ -16,9 +16,12 @@ import type { Frame, Page } from 'playwright-core'; import { ZipFile } from '../../packages/playwright-core/lib/utils/zipFile'; +import type { TraceModelBackend } from '../../packages/trace-viewer/src/traceModel'; import type { StackFrame } from '../../packages/protocol/src/channels'; import { parseClientSideCallMetadata } from '../../packages/playwright-core/lib/utils/isomorphic/traceUtils'; -import type { ActionTraceEvent, TraceEvent } from '../../packages/trace/src/trace'; +import { TraceModel } from '../../packages/trace-viewer/src/traceModel'; +import { MultiTraceModel } from '../../packages/trace-viewer/src/ui/modelUtil'; +import type { ActionTraceEvent, EventTraceEvent, TraceEvent } from '@trace/trace'; export async function attachFrame(page: Page, frameId: string, url: string): Promise { 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, actions: string[], stacks: Map }> { +export async function parseTraceRaw(file: string): Promise<{ events: any[], resources: Map, actions: string[], stacks: Map }> { const zipFS = new ZipFile(file); const resources = new Map(); for (const entry of await zipFS.entries()) @@ -162,6 +165,21 @@ function eventsToActions(events: ActionTraceEvent[]): string[] { .map(e => e.apiName); } +export async function parseTrace(file: string): Promise<{ resources: Map, 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> { const zipFS = new ZipFile(file); const resources = new Map(); @@ -187,3 +205,55 @@ const ansiRegex = new RegExp('[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*( export function stripAnsi(str: string): string { return str.replace(ansiRegex, ''); } + + +class TraceBackend implements TraceModelBackend { + private _fileName: string; + private _entriesPromise: Promise>; + readonly entries = new Map(); + + constructor(fileName: string) { + this._fileName = fileName; + this._entriesPromise = this._readEntries(); + } + + private async _readEntries(): Promise> { + 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 { + const entries = await this._entriesPromise; + return [...entries.keys()]; + } + + async hasEntry(entryName: string): Promise { + const entries = await this._entriesPromise; + return entries.has(entryName); + } + + async readText(entryName: string): Promise { + 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; + } +} diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index c3d44e6383..edc40ccba9 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import { jpegjs } from 'playwright-core/lib/utilsBundle'; import path from 'path'; import { browserTest, contextTest as test, expect } from '../config/browserTest'; -import { parseTrace } from '../config/utils'; +import { parseTraceRaw } from '../config/utils'; import type { StackFrame } from '@protocol/channels'; import type { ActionTraceEvent } from '../../packages/trace/src/trace'; @@ -36,7 +36,7 @@ test('should collect trace with resources, but no js', async ({ context, page, s await page.close(); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, actions } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.goto', @@ -77,7 +77,7 @@ test('should use the correct apiName for event driven callbacks', async ({ conte await page.evaluate(() => alert('yo')); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, actions } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.route', @@ -99,7 +99,7 @@ test('should not collect snapshots by default', async ({ context, page, server } await page.close(); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy(); expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy(); }); @@ -111,7 +111,7 @@ test('should not include buffers in the trace', async ({ context, page, server, await page.goto(server.PREFIX + '/empty.html'); await page.screenshot(); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const screenshotEvent = events.find(e => e.type === 'action' && e.apiName === 'page.screenshot'); expect(screenshotEvent.beforeSnapshot).toBeTruthy(); expect(screenshotEvent.afterSnapshot).toBeTruthy(); @@ -126,7 +126,7 @@ test('should exclude internal pages', async ({ browserName, context, page, serve await page.close(); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const trace = await parseTrace(testInfo.outputPath('trace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('trace.zip')); const pageIds = new Set(); trace.events.forEach(e => { const pageId = e.pageId; @@ -140,7 +140,7 @@ test('should include context API requests', async ({ browserName, context, page, await context.tracing.start({ snapshots: true }); await page.request.post(server.PREFIX + '/simple.json', { data: { foo: 'bar' } }); await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const postEvent = events.find(e => e.apiName === 'apiRequestContext.post'); expect(postEvent).toBeTruthy(); const harEntry = events.find(e => e.type === 'resource-snapshot'); @@ -162,7 +162,7 @@ test('should collect two traces', async ({ context, page, server }, testInfo) => await context.tracing.stop({ path: testInfo.outputPath('trace2.zip') }); { - const { events, actions } = await parseTrace(testInfo.outputPath('trace1.zip')); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.goto', @@ -172,7 +172,7 @@ test('should collect two traces', async ({ context, page, server }, testInfo) => } { - const { events, actions } = await parseTrace(testInfo.outputPath('trace2.zip')); + const { events, actions } = await parseTraceRaw(testInfo.outputPath('trace2.zip')); expect(events[0].type).toBe('context-options'); expect(actions).toEqual([ 'page.dblclick', @@ -208,7 +208,7 @@ test('should respect tracesDir and name', async ({ browserType, server }, testIn } { - const { resources, actions } = await parseTrace(testInfo.outputPath('trace1.zip')); + const { resources, actions } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); expect(actions).toEqual(['page.goto']); expect(resourceNames(resources)).toEqual([ 'resources/XXX.css', @@ -220,7 +220,7 @@ test('should respect tracesDir and name', async ({ browserType, server }, testIn } { - const { resources, actions } = await parseTrace(testInfo.outputPath('trace2.zip')); + const { resources, actions } = await parseTraceRaw(testInfo.outputPath('trace2.zip')); expect(actions).toEqual(['page.goto']); expect(resourceNames(resources)).toEqual([ 'resources/XXX.css', @@ -249,7 +249,7 @@ test('should not include trace resources from the provious chunks', async ({ con await context.tracing.stopChunk({ path: testInfo.outputPath('trace2.zip') }); { - const { resources } = await parseTrace(testInfo.outputPath('trace1.zip')); + const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); const names = Array.from(resources.keys()); expect(names.filter(n => n.endsWith('.html')).length).toBe(1); expect(names.filter(n => n.endsWith('.jpeg')).length).toBeGreaterThan(0); @@ -258,7 +258,7 @@ test('should not include trace resources from the provious chunks', async ({ con } { - const { resources } = await parseTrace(testInfo.outputPath('trace2.zip')); + const { resources } = await parseTraceRaw(testInfo.outputPath('trace2.zip')); const names = Array.from(resources.keys()); // 1 network resource should be preserved. expect(names.filter(n => n.endsWith('.html')).length).toBe(1); @@ -276,7 +276,7 @@ test('should overwrite existing file', async ({ context, page, server }, testInf const path = testInfo.outputPath('trace1.zip'); await context.tracing.stop({ path }); { - const { resources } = await parseTrace(path); + const { resources } = await parseTraceRaw(path); const names = Array.from(resources.keys()); expect(names.filter(n => n.endsWith('.html')).length).toBe(1); } @@ -285,7 +285,7 @@ test('should overwrite existing file', async ({ context, page, server }, testInf await context.tracing.stop({ path }); { - const { resources } = await parseTrace(path); + const { resources } = await parseTraceRaw(path); const names = Array.from(resources.keys()); expect(names.filter(n => n.endsWith('.html')).length).toBe(0); } @@ -298,7 +298,7 @@ test('should collect sources', async ({ context, page, server }, testInfo) => { await page.click('"Click"'); await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') }); - const { resources } = await parseTrace(testInfo.outputPath('trace1.zip')); + const { resources } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); const sourceNames = Array.from(resources.keys()).filter(k => k.endsWith('.txt')); expect(sourceNames.length).toBe(1); const sourceFile = resources.get(sourceNames[0]); @@ -312,7 +312,7 @@ test('should record network failures', async ({ context, page, server }, testInf await page.goto(server.EMPTY_PAGE).catch(e => {}); await context.tracing.stop({ path: testInfo.outputPath('trace1.zip') }); - const { events } = await parseTrace(testInfo.outputPath('trace1.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace1.zip')); const requestEvent = events.find(e => e.type === 'resource-snapshot' && !!e.snapshot.response._failureText); expect(requestEvent).toBeTruthy(); }); @@ -370,7 +370,7 @@ for (const params of [ } await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); - const { events, resources } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const frames = events.filter(e => e.type === 'screencast-frame'); // Check all frame sizes. @@ -403,7 +403,7 @@ test('should include interrupted actions', async ({ context, page, server }, tes await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); await context.close(); - const { events } = await parseTrace(testInfo.outputPath('trace.zip')); + const { events } = await parseTraceRaw(testInfo.outputPath('trace.zip')); const clickEvent = events.find(e => e.apiName === 'page.click'); expect(clickEvent).toBeTruthy(); }); @@ -441,7 +441,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI await page.click('"Click"'); await context.tracing.stopChunk(); // Should stop without a path. - const trace1 = await parseTrace(testInfo.outputPath('trace.zip')); + const trace1 = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(trace1.events[0].type).toBe('context-options'); expect(trace1.actions).toEqual([ 'page.setContent', @@ -451,7 +451,7 @@ test('should work with multiple chunks', async ({ context, page, server }, testI expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBeTruthy(); expect(trace1.events.some(e => e.type === 'resource-snapshot' && e.snapshot.request.url.endsWith('style.css'))).toBeTruthy(); - const trace2 = await parseTrace(testInfo.outputPath('trace2.zip')); + const trace2 = await parseTraceRaw(testInfo.outputPath('trace2.zip')); expect(trace2.events[0].type).toBe('context-options'); expect(trace2.actions).toEqual([ 'page.hover', @@ -501,7 +501,7 @@ test('should ignore iframes in head', async ({ context, page, server }, testInfo await page.click('button'); await context.tracing.stopChunk({ path: testInfo.outputPath('trace.zip') }); - const trace = await parseTrace(testInfo.outputPath('trace.zip')); + const trace = await parseTraceRaw(testInfo.outputPath('trace.zip')); expect(trace.actions).toEqual([ 'page.click', ]); @@ -522,7 +522,7 @@ test('should hide internal stack frames', async ({ context, page }, testInfo) => const tracePath = testInfo.outputPath('trace.zip'); await context.tracing.stop({ path: tracePath }); - const trace = await parseTrace(tracePath); + const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'action' && !e.apiName.startsWith('tracing.')); expect(actions).toHaveLength(4); for (const action of actions) @@ -543,7 +543,7 @@ test('should hide internal stack frames in expect', async ({ context, page }, te const tracePath = testInfo.outputPath('trace.zip'); await context.tracing.stop({ path: tracePath }); - const trace = await parseTrace(tracePath); + const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'action' && !e.apiName.startsWith('tracing.')); expect(actions).toHaveLength(5); for (const action of actions) @@ -557,7 +557,7 @@ test('should record global request trace', async ({ request, context, server }, const tracePath = testInfo.outputPath('trace.zip'); await (request as any)._tracing.stop({ path: tracePath }); - const trace = await parseTrace(tracePath); + const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'resource-snapshot'); expect(actions).toHaveLength(1); expect(actions[0].snapshot.request).toEqual(expect.objectContaining({ @@ -594,7 +594,7 @@ test('should store global request traces separately', async ({ request, server, (request2 as any)._tracing.stop({ path: trace2Path }) ]); { - const trace = await parseTrace(tracePath); + const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'resource-snapshot'); expect(actions).toHaveLength(1); expect(actions[0].snapshot.request).toEqual(expect.objectContaining({ @@ -603,7 +603,7 @@ test('should store global request traces separately', async ({ request, server, })); } { - const trace = await parseTrace(trace2Path); + const trace = await parseTraceRaw(trace2Path); const actions = trace.events.filter(e => e.type === 'resource-snapshot'); expect(actions).toHaveLength(1); expect(actions[0].snapshot.request).toEqual(expect.objectContaining({ @@ -623,7 +623,7 @@ test('should store postData for global request', async ({ request, server }, tes const tracePath = testInfo.outputPath('trace.zip'); await (request as any)._tracing.stop({ path: tracePath }); - const trace = await parseTrace(tracePath); + const trace = await parseTraceRaw(tracePath); const actions = trace.events.filter(e => e.type === 'resource-snapshot'); expect(actions).toHaveLength(1); const req = actions[0].snapshot.request; diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 9d3b6be14a..0bf9eda45d 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -22,7 +22,7 @@ import { spawnSync } from 'child_process'; import { PNG, jpegjs } from 'playwright-core/lib/utilsBundle'; import { registry } from '../../packages/playwright-core/lib/server'; import { rewriteErrorMessage } from '../../packages/playwright-core/lib/utils/stackTrace'; -import { parseTrace } from '../config/utils'; +import { parseTraceRaw } from '../config/utils'; const ffmpeg = registry.findExecutable('ffmpeg')!.executablePath('javascript'); @@ -773,7 +773,7 @@ it.describe('screencast', () => { const videoFile = await page.video().path(); expectRedFrames(videoFile, size); - const { events, resources } = await parseTrace(traceFile); + const { events, resources } = await parseTraceRaw(traceFile); const frame = events.filter(e => e.type === 'screencast-frame').pop(); const buffer = resources.get('resources/' + frame.sha1); const image = jpegjs.decode(buffer); diff --git a/tests/playwright-test/playwright.reuse.spec.ts b/tests/playwright-test/playwright.reuse.spec.ts index 5601c5f302..381afb96d4 100644 --- a/tests/playwright-test/playwright.reuse.spec.ts +++ b/tests/playwright-test/playwright.reuse.spec.ts @@ -144,22 +144,29 @@ test('should reuse context with trace if mode=when-possible', async ({ runInline expect(result.passed).toBe(2); const trace1 = await parseTrace(testInfo.outputPath('test-results', 'reuse-one', 'trace.zip')); - expect(trace1.actions).toEqual([ + expect(trace1.apiNames).toEqual([ + 'Before Hooks', + 'browserType.launch', 'browserContext.newPage', 'page.setContent', 'page.click', + 'After Hooks', + 'tracing.stopChunk', ]); - expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBe(true); + expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); expect(fs.existsSync(testInfo.outputPath('test-results', 'reuse-one', 'trace-1.zip'))).toBe(false); const trace2 = await parseTrace(testInfo.outputPath('test-results', 'reuse-two', 'trace.zip')); - expect(trace2.actions).toEqual([ + expect(trace2.apiNames).toEqual([ + 'Before Hooks', 'expect.toBe', 'page.setContent', 'page.fill', 'locator.click', + 'After Hooks', + 'tracing.stopChunk', ]); - expect(trace2.events.some(e => e.type === 'frame-snapshot')).toBe(true); + expect(trace2.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); }); test('should work with manually closed pages', async ({ runInlineTest }) => { @@ -481,19 +488,19 @@ test('should reset tracing', async ({ runInlineTest }, testInfo) => { expect(result.passed).toBe(2); const trace1 = await parseTrace(traceFile1); - expect(trace1.actions).toEqual([ + expect(trace1.apiNames).toEqual([ 'page.setContent', 'page.click', ]); - expect(trace1.events.some(e => e.type === 'frame-snapshot')).toBe(true); + expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); const trace2 = await parseTrace(traceFile2); - expect(trace2.actions).toEqual([ + expect(trace2.apiNames).toEqual([ 'page.setContent', 'page.fill', 'locator.click', ]); - expect(trace2.events.some(e => e.type === 'frame-snapshot')).toBe(true); + expect(trace1.traceModel.storage().snapshotsForTest().length).toBeGreaterThan(0); }); test('should not delete others contexts', async ({ runInlineTest }) => { diff --git a/tests/playwright-test/playwright.trace.spec.ts b/tests/playwright-test/playwright.trace.spec.ts index 9a2c349023..c4de3cb1cb 100644 --- a/tests/playwright-test/playwright.trace.spec.ts +++ b/tests/playwright-test/playwright.trace.spec.ts @@ -87,11 +87,49 @@ test('should record api trace', async ({ runInlineTest, server }, testInfo) => { expect(result.failed).toBe(1); // One trace file for request context and one for each APIRequestContext const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-pass', 'trace.zip')); - expect(trace1.actions).toEqual(['browserContext.newPage', 'page.goto', 'apiRequestContext.get']); + expect(trace1.apiNames).toEqual([ + 'Before Hooks', + 'apiRequest.newContext', + 'tracing.start', + 'browserType.launch', + 'browser.newContext', + 'tracing.start', + 'browserContext.newPage', + 'page.goto', + 'apiRequestContext.get', + 'After Hooks', + 'browserContext.close', + 'tracing.stopChunk', + 'apiRequestContext.dispose', + ]); const trace2 = await parseTrace(testInfo.outputPath('test-results', 'a-api-pass', 'trace.zip')); - expect(trace2.actions).toEqual(['apiRequestContext.get']); + expect(trace2.apiNames).toEqual([ + 'Before Hooks', + 'apiRequest.newContext', + 'tracing.start', + 'apiRequestContext.get', + 'After Hooks', + 'tracing.stopChunk', + ]); const trace3 = await parseTrace(testInfo.outputPath('test-results', 'a-fail', 'trace.zip')); - expect(trace3.actions).toEqual(['browserContext.newPage', 'page.goto', 'apiRequestContext.get', 'expect.toBe']); + expect(trace3.apiNames).toEqual([ + 'Before Hooks', + 'tracing.startChunk', + 'apiRequest.newContext', + 'tracing.start', + 'browser.newContext', + 'tracing.start', + 'browserContext.newPage', + 'page.goto', + 'apiRequestContext.get', + 'expect.toBe', + 'After Hooks', + 'browserContext.close', + 'tracing.stopChunk', + 'apiRequestContext.dispose', + 'browser.close', + 'tracing.stopChunk', + ]); }); @@ -275,7 +313,25 @@ test('should not override trace file in afterAll', async ({ runInlineTest, serve expect(result.passed).toBe(1); expect(result.failed).toBe(1); const trace1 = await parseTrace(testInfo.outputPath('test-results', 'a-test-1', 'trace.zip')); - expect(trace1.actions).toEqual(['browserContext.newPage', 'page.goto', 'apiRequestContext.get']); + + expect(trace1.apiNames).toEqual([ + 'Before Hooks', + 'browserType.launch', + 'browser.newContext', + 'tracing.start', + 'browserContext.newPage', + 'page.goto', + 'After Hooks', + 'browserContext.close', + 'afterAll hook', + 'apiRequest.newContext', + 'tracing.start', + 'apiRequestContext.get', + 'tracing.stopChunk', + 'apiRequestContext.dispose', + 'browser.close', + ]); + const error = await parseTrace(testInfo.outputPath('test-results', 'a-test-2', 'trace.zip')).catch(e => e); expect(error).toBeTruthy(); }); @@ -409,7 +465,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa }, { trace: 'retain-on-failure' }); const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); - expect(trace.actions).toContain('page.goto'); + expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); }); @@ -431,7 +487,7 @@ test(`trace:retain-on-failure should create trace if context is closed before fa }, { trace: 'retain-on-failure' }); const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); - expect(trace.actions).toContain('page.goto'); + expect(trace.apiNames).toContain('page.goto'); expect(result.failed).toBe(1); }); @@ -451,6 +507,6 @@ test(`trace:retain-on-failure should create trace if request context is disposed }, { trace: 'retain-on-failure' }); const tracePath = test.info().outputPath('test-results', 'a-passing-test', 'trace.zip'); const trace = await parseTrace(tracePath); - expect(trace.actions).toContain('apiRequestContext.get'); + expect(trace.apiNames).toContain('apiRequestContext.get'); expect(result.failed).toBe(1); }); diff --git a/tests/playwright-test/ui-mode-fixtures.ts b/tests/playwright-test/ui-mode-fixtures.ts index ef46184ba6..0bd52a997b 100644 --- a/tests/playwright-test/ui-mode-fixtures.ts +++ b/tests/playwright-test/ui-mode-fixtures.ts @@ -125,7 +125,9 @@ export const test = base }); import { expect as baseExpect } from './stable-test-runner'; -export const expect = baseExpect.configure({ timeout: 0 }); + +// Slow tests are 90s. +export const expect = baseExpect.configure({ timeout: process.env.CI ? 75000 : 25000 }); async function waitForLatch(latchFile: string) { const fs = require('fs'); diff --git a/tests/playwright-test/ui-mode-test-progress.spec.ts b/tests/playwright-test/ui-mode-test-progress.spec.ts index f8de9801af..8b325a97c7 100644 --- a/tests/playwright-test/ui-mode-test-progress.spec.ts +++ b/tests/playwright-test/ui-mode-test-progress.spec.ts @@ -103,9 +103,12 @@ test('should update trace live', async ({ runUITest, server }) => { ).toHaveText('Two'); await expect(listItem).toHaveText([ + /Before Hooks[\d.]+m?s/, /browserContext.newPage[\d.]+m?s/, - /page.gotohttp:\/\/localhost:\d+\/one.html[\d.]+m?s/, - /page.gotohttp:\/\/localhost:\d+\/two.html[\d.]+m?s/ + /page.gotohttp:\/\/localhost:\d+\/one.html/, + /page.gotohttp:\/\/localhost:\d+\/two.html/, + /After Hooks[\d.]+m?s/, + /browserContext.close[\d.]+m?s/, ]); }); diff --git a/tests/playwright-test/ui-mode-trace.spec.ts b/tests/playwright-test/ui-mode-trace.spec.ts index 91ba32b003..d28de3bfe5 100644 --- a/tests/playwright-test/ui-mode-trace.spec.ts +++ b/tests/playwright-test/ui-mode-trace.spec.ts @@ -38,11 +38,15 @@ test('should merge trace events', async ({ runUITest, server }) => { listItem, 'action list' ).toHaveText([ - /browserContext\.newPage[\d.]+m?s/, - /page\.setContent[\d.]+m?s/, - /expect\.toBe[\d.]+m?s/, - /locator\.clickgetByRole\('button'\)[\d.]+m?s/, - /expect\.toBe[\d.]+m?s/, + /Before Hooks[\d.]+m?s/, + /browserContext.newPage[\d.]+m?s/, + /page.setContent[\d.]+m?s/, + /expect.toBe[\d.]+m?s/, + /locator.clickgetByRole\('button'\)[\d.]+m?s/, + /expect.toBe[\d.]+m?s/, + /After Hooks[\d.]+m?s/, + /browserContext.close[\d.]+m?s/, + ]); }); @@ -64,9 +68,12 @@ test('should merge web assertion events', async ({ runUITest }, testInfo) => { listItem, 'action list' ).toHaveText([ - /browserContext\.newPage[\d.]+m?s/, - /page\.setContent[\d.]+m?s/, - /expect\.toBeVisiblelocator\('button'\)[\d.]+m?s/, + /Before Hooks[\d.]+m?s/, + /browserContext.newPage[\d.]+m?s/, + /page.setContent[\d.]+m?s/, + /expect.toBeVisiblelocator\('button'\)[\d.]+m?s/, + /After Hooks[\d.]+m?s/, + /browserContext.close[\d.]+m?s/, ]); }); @@ -84,13 +91,16 @@ test('should merge screenshot assertions', async ({ runUITest }, testInfo) => { await page.getByText('trace test').dblclick(); const listItem = page.getByTestId('action-list').getByRole('listitem'); + // TODO: fixme. await expect( listItem, 'action list' ).toHaveText([ - /browserContext\.newPage[\d.]+m?s/, + /Before Hooks[\d.]+m?s/, + /browserContext.newPage[\d.]+m?s/, /page\.setContent[\d.]+m?s/, /expect\.toHaveScreenshot[\d.]+m?s/, + /After Hooks/, ]); }); @@ -105,6 +115,7 @@ test('should locate sync assertions in source', async ({ runUITest, server }) => }); await page.getByText('trace test').dblclick(); + await page.getByText('expect.toBe').click(); await expect( page.locator('.CodeMirror .source-line-running'), @@ -131,10 +142,13 @@ test('should show snapshots for sync assertions', async ({ runUITest, server }) listItem, 'action list' ).toHaveText([ + /Before Hooks[\d.]+m?s/, /browserContext\.newPage[\d.]+m?s/, /page\.setContent[\d.]+m?s/, /locator\.clickgetByRole\('button'\)[\d.]+m?s/, /expect\.toBe[\d.]+m?s/, + /After Hooks[\d.]+m?s/, + /browserContext.close[\d.]+m?s/, ]); await expect( diff --git a/tests/playwright-test/watch.spec.ts b/tests/playwright-test/watch.spec.ts index 6cf7115d71..2714d5275f 100644 --- a/tests/playwright-test/watch.spec.ts +++ b/tests/playwright-test/watch.spec.ts @@ -92,8 +92,8 @@ test('should print dependencies in ESM mode', async ({ runInlineTest, nodeVersio const output = result.output; const deps = JSON.parse(output.match(/###(.*)###/)![1]); expect(deps).toEqual({ - 'a.test.ts': ['helperA.ts'], - 'b.test.ts': ['helperA.ts', 'helperB.ts'], + 'a.test.ts': ['helperA.ts', 'index.mjs'], + 'b.test.ts': ['helperA.ts', 'helperB.ts', 'index.mjs'], }); }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 409252ec4f..361dcb03b4 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -11,6 +11,7 @@ "useUnknownInCatchVariables": false, "baseUrl": "..", "paths": { + "@isomorphic/*": ["packages/playwright-core/src/utils/isomorphic/*"], "@protocol/*": ["packages/protocol/src/*"], "@recorder/*": ["packages/recorder/src/*"], "@trace/*": ["packages/trace/src/*"],