diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 6cbd2310ce..fec1275c74 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -140,11 +140,10 @@ program if (process.env.PWTRACE) { program - .command('show-trace ') + .command('show-trace [trace]') .description('Show trace viewer') - .option('--resources ', 'Directory with the shared trace artifacts') .action(function(trace, command) { - showTraceViewer(command.resources, trace); + showTraceViewer(trace); }).on('--help', function() { console.log(''); console.log('Examples:'); diff --git a/src/cli/traceViewer/traceViewer.ts b/src/cli/traceViewer/traceViewer.ts index 1a7b04c125..120d34f02e 100644 --- a/src/cli/traceViewer/traceViewer.ts +++ b/src/cli/traceViewer/traceViewer.ts @@ -26,27 +26,66 @@ import { VideoTileGenerator } from './videoTileGenerator'; const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); -class TraceViewer { - private _traceStorageDir: string; - private _traceModel: TraceModel; - private _snapshotRouter: SnapshotRouter; - private _screenshotGenerator: ScreenshotGenerator; - private _videoTileGenerator: VideoTileGenerator; +type TraceViewerDocument = { + resourcesDir: string; + model: TraceModel; + snapshotRouter: SnapshotRouter; + screenshotGenerator: ScreenshotGenerator; + videoTileGenerator: VideoTileGenerator; +}; - constructor(traceStorageDir: string) { - this._traceStorageDir = traceStorageDir; - this._snapshotRouter = new SnapshotRouter(traceStorageDir); - this._traceModel = { - contexts: [], - }; - this._screenshotGenerator = new ScreenshotGenerator(traceStorageDir, this._traceModel); - this._videoTileGenerator = new VideoTileGenerator(this._traceModel); +const emptyModel: TraceModel = { + contexts: [ + { + startTime: 0, + endTime: 1, + created: { + timestamp: Date.now(), + type: 'context-created', + browserName: 'none', + contextId: '', + deviceScaleFactor: 1, + isMobile: false, + viewportSize: { width: 800, height: 600 }, + }, + destroyed: { + timestamp: Date.now(), + type: 'context-destroyed', + contextId: '', + }, + name: '', + filePath: '', + pages: [], + resourcesByUrl: new Map() + } + ] +}; + +class TraceViewer { + private _document: TraceViewerDocument | undefined; + + constructor() { } - async load(filePath: string) { - const traceContent = await fsReadFileAsync(filePath, 'utf8'); - const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[]; - readTraceFile(events, this._traceModel, filePath); + async load(traceDir: string) { + const resourcesDir = path.join(traceDir, 'resources'); + const model = { contexts: [] }; + this._document = { + model, + resourcesDir, + snapshotRouter: new SnapshotRouter(resourcesDir), + screenshotGenerator: new ScreenshotGenerator(resourcesDir, model), + videoTileGenerator: new VideoTileGenerator(model) + }; + + for (const name of fs.readdirSync(traceDir)) { + if (!name.endsWith('.trace')) + continue; + const filePath = path.join(traceDir, name); + const traceContent = await fsReadFileAsync(filePath, 'utf8'); + const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[]; + readTraceFile(events, model, filePath); + } } async show() { @@ -57,6 +96,8 @@ class TraceViewer { return fs.readFileSync(path).toString(); }); await uiPage.exposeBinding('renderSnapshot', async (_, action: ActionTraceEvent) => { + if (!this._document) + return; try { if (!action.snapshot) { const snapshotFrame = uiPage.frames()[1]; @@ -64,10 +105,10 @@ class TraceViewer { return; } - const snapshot = await fsReadFileAsync(path.join(this._traceStorageDir, action.snapshot!.sha1), 'utf8'); + const snapshot = await fsReadFileAsync(path.join(this._document.resourcesDir, action.snapshot!.sha1), 'utf8'); const snapshotObject = JSON.parse(snapshot) as PageSnapshot; - const contextEntry = this._traceModel.contexts.find(entry => entry.created.contextId === action.contextId)!; - this._snapshotRouter.selectSnapshot(snapshotObject, contextEntry); + const contextEntry = this._document.model.contexts.find(entry => entry.created.contextId === action.contextId)!; + this._document.snapshotRouter.selectSnapshot(snapshotObject, contextEntry); // TODO: fix Playwright bug where frame.name is lost (empty). const snapshotFrame = uiPage.frames()[1]; @@ -88,21 +129,21 @@ class TraceViewer { console.log(e); // eslint-disable-line no-console } }); - await uiPage.exposeBinding('getTraceModel', () => this._traceModel); + await uiPage.exposeBinding('getTraceModel', () => this._document ? this._document.model : emptyModel); await uiPage.exposeBinding('getVideoMetaInfo', async (_, videoId: string) => { - return this._videoTileGenerator.render(videoId); + return this._document ? this._document.videoTileGenerator.render(videoId) : null; }); await uiPage.route('**/*', (route, request) => { - if (request.frame().parentFrame()) { - this._snapshotRouter.route(route); + if (request.frame().parentFrame() && this._document) { + this._document.snapshotRouter.route(route); return; } const url = new URL(request.url()); try { - if (request.url().includes('action-preview')) { + if (this._document && request.url().includes('action-preview')) { const fullPath = url.pathname.substring('/action-preview/'.length); const actionId = fullPath.substring(0, fullPath.indexOf('.png')); - this._screenshotGenerator.generateScreenshot(actionId).then(body => { + this._document.screenshotGenerator.generateScreenshot(actionId).then(body => { if (body) route.fulfill({ contentType: 'image/png', body }); else @@ -111,9 +152,9 @@ class TraceViewer { return; } let filePath: string; - if (request.url().includes('video-tile')) { + if (this._document && request.url().includes('video-tile')) { const fullPath = url.pathname.substring('/video-tile/'.length); - filePath = this._videoTileGenerator.tilePath(fullPath); + filePath = this._document.videoTileGenerator.tilePath(fullPath); } else { filePath = path.join(__dirname, 'web', url.pathname.substring(1)); } @@ -133,37 +174,13 @@ class TraceViewer { } } -export async function showTraceViewer(traceStorageDir: string | undefined, tracePath: string) { - if (!fs.existsSync(tracePath)) - throw new Error(`${tracePath} does not exist`); - - const files: string[] = fs.statSync(tracePath).isFile() ? [tracePath] : collectFiles(tracePath); - - if (!traceStorageDir) { - traceStorageDir = fs.statSync(tracePath).isFile() ? path.dirname(tracePath) : tracePath; - - if (fs.existsSync(traceStorageDir + '/trace-resources')) - traceStorageDir = traceStorageDir + '/trace-resources'; - } - - const traceViewer = new TraceViewer(traceStorageDir); - for (const filePath of files) - await traceViewer.load(filePath); +export async function showTraceViewer(traceDir: string) { + const traceViewer = new TraceViewer(); + if (traceDir) + await traceViewer.load(traceDir); await traceViewer.show(); } -function collectFiles(dir: string): string[] { - const files = []; - for (const name of fs.readdirSync(dir)) { - const fullName = path.join(dir, name); - if (fs.lstatSync(fullName).isDirectory()) - files.push(...collectFiles(fullName)); - else if (fullName.endsWith('.trace')) - files.push(fullName); - } - return files; -} - const extensionToMime: { [key: string]: string } = { 'css': 'text/css', 'html': 'text/html', diff --git a/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx b/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx index 8df868c964..1d1eba4591 100644 --- a/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx +++ b/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx @@ -80,7 +80,7 @@ const SnapshotTab: React.FunctionComponent<{ React.useEffect(() => { if (actionEntry) - window.renderSnapshot(actionEntry.action); + (window as any).renderSnapshot(actionEntry.action); }, [actionEntry]); const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height); diff --git a/src/cli/traceViewer/web/web.webpack.config.js b/src/cli/traceViewer/web/web.webpack.config.js index dedec1cf60..1707c02f55 100644 --- a/src/cli/traceViewer/web/web.webpack.config.js +++ b/src/cli/traceViewer/web/web.webpack.config.js @@ -9,6 +9,7 @@ module.exports = { resolve: { extensions: ['.ts', '.js', '.tsx', '.jsx'] }, + devtool: 'source-map', output: { globalObject: 'self', filename: '[name].bundle.js', diff --git a/src/client/browser.ts b/src/client/browser.ts index 52f24ee270..f95b90e208 100644 --- a/src/client/browser.ts +++ b/src/client/browser.ts @@ -45,8 +45,8 @@ export class Browser extends ChannelOwner { return this._wrapApiCall('browser.newContext', async () => { - if (this._isRemote && options._tracePath) - throw new Error(`"_tracePath" is not supported in connected browser`); + if (this._isRemote && options._traceDir) + throw new Error(`"_traceDir" is not supported in connected browser`); const contextOptions = await prepareBrowserContextOptions(options); const context = BrowserContext.from((await this._channel.newContext(contextOptions)).context); context._options = contextOptions; diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 582238705e..53b4c235a6 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -297,8 +297,7 @@ export type BrowserTypeLaunchPersistentContextParams = { hasTouch?: boolean, colorScheme?: 'light' | 'dark' | 'no-preference', acceptDownloads?: boolean, - _traceResourcesPath?: string, - _tracePath?: string, + _traceDir?: string, recordVideo?: { dir: string, size?: { @@ -360,8 +359,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { hasTouch?: boolean, colorScheme?: 'light' | 'dark' | 'no-preference', acceptDownloads?: boolean, - _traceResourcesPath?: string, - _tracePath?: string, + _traceDir?: string, recordVideo?: { dir: string, size?: { @@ -424,8 +422,7 @@ export type BrowserNewContextParams = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceResourcesPath?: string, - _tracePath?: string, + _traceDir?: string, recordVideo?: { dir: string, size?: { @@ -477,8 +474,7 @@ export type BrowserNewContextOptions = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceResourcesPath?: string, - _tracePath?: string, + _traceDir?: string, recordVideo?: { dir: string, size?: { @@ -2772,8 +2768,7 @@ export type AndroidDeviceLaunchBrowserParams = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceResourcesPath?: string, - _tracePath?: string, + _traceDir?: string, recordVideo?: { dir: string, size?: { @@ -2817,8 +2812,7 @@ export type AndroidDeviceLaunchBrowserOptions = { hasTouch?: boolean, colorScheme?: 'dark' | 'light' | 'no-preference', acceptDownloads?: boolean, - _traceResourcesPath?: string, - _tracePath?: string, + _traceDir?: string, recordVideo?: { dir: string, size?: { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index afb0948384..c025470277 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -372,8 +372,7 @@ BrowserType: - dark - no-preference acceptDownloads: boolean? - _traceResourcesPath: string? - _tracePath: string? + _traceDir: string? recordVideo: type: object? properties: @@ -445,8 +444,7 @@ Browser: - light - no-preference acceptDownloads: boolean? - _traceResourcesPath: string? - _tracePath: string? + _traceDir: string? recordVideo: type: object? properties: @@ -2336,8 +2334,7 @@ AndroidDevice: - light - no-preference acceptDownloads: boolean? - _traceResourcesPath: string? - _tracePath: string? + _traceDir: string? recordVideo: type: object? properties: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index ceb75d7e7a..dcc408744a 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -211,8 +211,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['light', 'dark', 'no-preference'])), acceptDownloads: tOptional(tBoolean), - _traceResourcesPath: tOptional(tString), - _tracePath: tOptional(tString), + _traceDir: tOptional(tString), recordVideo: tOptional(tObject({ dir: tString, size: tOptional(tObject({ @@ -255,8 +254,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), acceptDownloads: tOptional(tBoolean), - _traceResourcesPath: tOptional(tString), - _tracePath: tOptional(tString), + _traceDir: tOptional(tString), recordVideo: tOptional(tObject({ dir: tString, size: tOptional(tObject({ @@ -1040,8 +1038,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { hasTouch: tOptional(tBoolean), colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference'])), acceptDownloads: tOptional(tBoolean), - _traceResourcesPath: tOptional(tString), - _tracePath: tOptional(tString), + _traceDir: tOptional(tString), recordVideo: tOptional(tObject({ dir: tString, size: tOptional(tObject({ diff --git a/src/server/types.ts b/src/server/types.ts index 5b72ad97d6..ccf7c5338c 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -248,8 +248,7 @@ export type BrowserContextOptions = { path: string }, proxy?: ProxySettings, - _tracePath?: string, - _traceResourcesPath?: string, + _traceDir?: string, }; export type EnvArray = { name: string, value: string }[]; diff --git a/src/trace/tracer.ts b/src/trace/tracer.ts index b5c6ad694b..6d388d9279 100644 --- a/src/trace/tracer.ts +++ b/src/trace/tracer.ts @@ -43,17 +43,11 @@ class Tracer implements ContextListener { private _contextTracers = new Map(); async onContextCreated(context: BrowserContext): Promise { - let traceStorageDir: string; - let tracePath: string; - if (context._options._tracePath) { - traceStorageDir = context._options._traceResourcesPath || path.join(path.dirname(context._options._tracePath), 'trace-resources'); - tracePath = context._options._tracePath; - } else if (envTrace) { - traceStorageDir = envTrace; - tracePath = path.join(envTrace, createGuid() + '.trace'); - } else { + const traceDir = envTrace || context._options._traceDir; + if (!traceDir) return; - } + const traceStorageDir = path.join(traceDir, 'resources'); + const tracePath = path.join(traceDir, createGuid() + '.trace'); const contextTracer = new ContextTracer(context, traceStorageDir, tracePath); this._contextTracers.set(context, contextTracer); } diff --git a/test/trace.spec.ts b/test/trace.spec.ts index 135edb131d..00bfb1a218 100644 --- a/test/trace.spec.ts +++ b/test/trace.spec.ts @@ -20,14 +20,13 @@ import * as path from 'path'; import * as fs from 'fs'; it('should record trace', async ({browser, testInfo, server}) => { - const artifactsPath = testInfo.outputPath(''); - const tracePath = path.join(artifactsPath, 'playwright.trace'); - const context = await browser.newContext({ _tracePath: tracePath } as any); + const traceDir = testInfo.outputPath('trace'); + const context = await browser.newContext({ _traceDir: traceDir } as any); const page = await context.newPage(); const url = server.PREFIX + '/snapshot/snapshot-with-css.html'; await page.goto(url); await context.close(); - + const tracePath = path.join(traceDir, fs.readdirSync(traceDir).find(n => n.endsWith('.trace'))); const traceFileContent = await fs.promises.readFile(tracePath, 'utf8'); const traceEvents = traceFileContent.split('\n').filter(line => !!line).map(line => JSON.parse(line)) as trace.TraceEvent[]; @@ -47,5 +46,5 @@ it('should record trace', async ({browser, testInfo, server}) => { expect(gotoEvent.value).toBe(url); expect(gotoEvent.snapshot).toBeTruthy(); - expect(fs.existsSync(path.join(artifactsPath, 'trace-resources', gotoEvent.snapshot!.sha1))).toBe(true); + expect(fs.existsSync(path.join(traceDir, 'resources', gotoEvent.snapshot!.sha1))).toBe(true); });