diff --git a/src/cli/traceViewer/screenshotGenerator.ts b/src/cli/traceViewer/screenshotGenerator.ts index f44c0338c2..2ed6329e29 100644 --- a/src/cli/traceViewer/screenshotGenerator.ts +++ b/src/cli/traceViewer/screenshotGenerator.ts @@ -18,8 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as playwright from '../../..'; import * as util from 'util'; -import { SnapshotRouter } from './snapshotRouter'; import { actionById, ActionEntry, ContextEntry, PageEntry, TraceModel } from './traceModel'; +import { SnapshotServer } from './snapshotServer'; const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); @@ -27,14 +27,16 @@ const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); export class ScreenshotGenerator { private _traceStorageDir: string; private _browserPromise: Promise; + private _serverPromise: Promise; private _traceModel: TraceModel; private _rendering = new Map>(); private _lock = new Lock(3); - constructor(traceStorageDir: string, traceModel: TraceModel) { - this._traceStorageDir = traceStorageDir; + constructor(resourcesDir: string, traceModel: TraceModel) { + this._traceStorageDir = resourcesDir; this._traceModel = traceModel; this._browserPromise = playwright.chromium.launch(); + this._serverPromise = SnapshotServer.create(undefined, resourcesDir, traceModel, undefined); } generateScreenshot(actionId: string): Promise { @@ -58,6 +60,7 @@ export class ScreenshotGenerator { const { action } = actionEntry; const browser = await this._browserPromise; + const server = await this._serverPromise; await this._lock.obtain(); @@ -67,14 +70,17 @@ export class ScreenshotGenerator { }); try { - const snapshotRouter = new SnapshotRouter(this._traceStorageDir); + await page.goto(server.snapshotRootUrl()); + await page.evaluate(async () => { + navigator.serviceWorker.register('/service-worker.js'); + await new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve); + }); + const snapshots = action.snapshots || []; const snapshotId = snapshots.length ? snapshots[0].snapshotId : undefined; - const snapshotTimestamp = action.startTime; - const pageUrl = await snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshotId, snapshotTimestamp); - page.route('**/*', route => snapshotRouter.route(route)); - console.log('Generating screenshot for ' + action.action, pageUrl); // eslint-disable-line no-console - await page.goto(pageUrl); + const snapshotUrl = server.snapshotUrl(action.pageId!, snapshotId, action.endTime); + console.log('Generating screenshot for ' + action.action); // eslint-disable-line no-console + await page.evaluate(snapshotUrl => (window as any).showSnapshot(snapshotUrl), snapshotUrl); try { const element = await page.$(action.selector || '*[__playwright_target__]'); diff --git a/src/cli/traceViewer/snapshotRouter.ts b/src/cli/traceViewer/snapshotRouter.ts deleted file mode 100644 index f1da1f90cd..0000000000 --- a/src/cli/traceViewer/snapshotRouter.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * 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 * as fs from 'fs'; -import * as path from 'path'; -import * as util from 'util'; -import type { Frame, Route } from '../../..'; -import { parsedURL } from '../../client/clientHelper'; -import { ContextEntry, PageEntry, trace } from './traceModel'; - -const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); - -export class SnapshotRouter { - private _contextEntry: ContextEntry | undefined; - private _unknownUrls = new Set(); - private _resourcesDir: string; - private _snapshotFrameIdToSnapshot = new Map(); - private _pageUrl = ''; - private _frameToSnapshotFrameId = new Map(); - - constructor(resourcesDir: string) { - this._resourcesDir = resourcesDir; - } - - // Returns the url to navigate to. - async selectSnapshot(contextEntry: ContextEntry, pageEntry: PageEntry, snapshotId?: string, timestamp?: number): Promise { - this._contextEntry = contextEntry; - if (!snapshotId && !timestamp) - return 'data:text/html,Snapshot is not available'; - - const lastSnapshotEvent = new Map(); - for (const [frameId, snapshots] of pageEntry.snapshotsByFrameId) { - for (const snapshot of snapshots) { - const current = lastSnapshotEvent.get(frameId); - // Prefer snapshot with exact id. - const exactMatch = snapshotId && snapshot.snapshotId === snapshotId; - const currentExactMatch = current && snapshotId && current.snapshotId === snapshotId; - // If not available, prefer the latest snapshot before the timestamp. - const timestampMatch = timestamp && snapshot.timestamp <= timestamp; - if (exactMatch || (timestampMatch && !currentExactMatch)) - lastSnapshotEvent.set(frameId, snapshot); - } - } - - this._snapshotFrameIdToSnapshot.clear(); - for (const [frameId, event] of lastSnapshotEvent) { - const buffer = await this._readSha1(event.sha1); - if (!buffer) - continue; - try { - const snapshot = JSON.parse(buffer.toString('utf8')) as trace.FrameSnapshot; - // Request url could come lower case, so we always normalize to lower case. - this._snapshotFrameIdToSnapshot.set(frameId.toLowerCase(), snapshot); - } catch (e) { - } - } - - if (!lastSnapshotEvent.get('')) - return 'data:text/html,Snapshot is not available'; - this._pageUrl = 'http://playwright.snapshot/?cachebusting=' + Date.now(); - return this._pageUrl; - } - - async route(route: Route) { - const url = route.request().url(); - const frame = route.request().frame(); - - if (route.request().isNavigationRequest()) { - let snapshotFrameId: string | undefined; - if (url === this._pageUrl) { - snapshotFrameId = ''; - } else { - snapshotFrameId = url.substring(url.indexOf('://') + 3); - if (snapshotFrameId.endsWith('/')) - snapshotFrameId = snapshotFrameId.substring(0, snapshotFrameId.length - 1); - // Request url could come lower case, so we always normalize to lower case. - snapshotFrameId = snapshotFrameId.toLowerCase(); - } - - const snapshot = this._snapshotFrameIdToSnapshot.get(snapshotFrameId); - if (!snapshot) { - route.fulfill({ - contentType: 'text/html', - body: 'data:text/html,Snapshot is not available', - }); - return; - } - - this._frameToSnapshotFrameId.set(frame, snapshotFrameId); - route.fulfill({ - contentType: 'text/html', - body: snapshot.html, - }); - return; - } - - const snapshotFrameId = this._frameToSnapshotFrameId.get(frame); - if (snapshotFrameId === undefined) - return this._routeUnknown(route); - const snapshot = this._snapshotFrameIdToSnapshot.get(snapshotFrameId); - if (!snapshot) - return this._routeUnknown(route); - - // Find a matching resource from the same context, preferrably from the same frame. - // Note: resources are stored without hash, but page may reference them with hash. - let resource: trace.NetworkResourceTraceEvent | null = null; - const resourcesWithUrl = this._contextEntry!.resourcesByUrl.get(removeHash(url)) || []; - for (const resourceEvent of resourcesWithUrl) { - if (resource && resourceEvent.frameId !== snapshotFrameId) - continue; - resource = resourceEvent; - if (resourceEvent.frameId === snapshotFrameId) - break; - } - if (!resource) - return this._routeUnknown(route); - - // This particular frame might have a resource content override, for example when - // stylesheet is modified using CSSOM. - const resourceOverride = snapshot.resourceOverrides.find(o => o.url === url); - const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined; - const resourceData = await this._readResource(resource, overrideSha1); - if (!resourceData) - return this._routeUnknown(route); - const headers: { [key: string]: string } = {}; - for (const { name, value } of resourceData.headers) - headers[name] = value; - headers['Access-Control-Allow-Origin'] = '*'; - route.fulfill({ - contentType: resourceData.contentType, - body: resourceData.body, - headers, - }); - } - - private _routeUnknown(route: Route) { - const url = route.request().url(); - if (!this._unknownUrls.has(url)) { - console.log(`Request to unknown url: ${url}`); /* eslint-disable-line no-console */ - this._unknownUrls.add(url); - } - route.abort(); - } - - private async _readSha1(sha1: string) { - try { - return await fsReadFileAsync(path.join(this._resourcesDir, sha1)); - } catch (e) { - return undefined; - } - } - - private async _readResource(event: trace.NetworkResourceTraceEvent, overrideSha1: string | undefined) { - const body = await this._readSha1(overrideSha1 || event.responseSha1); - if (!body) - return; - return { - contentType: event.contentType, - body, - headers: event.responseHeaders, - }; - } -} - -function removeHash(url: string) { - const u = parsedURL(url); - if (!u) - return url; - u.hash = ''; - return u.toString(); -} diff --git a/src/cli/traceViewer/snapshotServer.ts b/src/cli/traceViewer/snapshotServer.ts new file mode 100644 index 0000000000..8b1e94cd17 --- /dev/null +++ b/src/cli/traceViewer/snapshotServer.ts @@ -0,0 +1,463 @@ +/** + * 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 * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { TraceModel, trace } from './traceModel'; +import type { ScreenshotGenerator } from './screenshotGenerator'; + +export class SnapshotServer { + static async create(traceViewerDir: string | undefined, resourcesDir: string | undefined, traceModel: TraceModel, screenshotGenerator: ScreenshotGenerator | undefined): Promise { + const server = new SnapshotServer(traceViewerDir, resourcesDir, traceModel, screenshotGenerator); + await new Promise(cb => server._server.once('listening', cb)); + return server; + } + + private _traceViewerDir: string | undefined; + private _resourcesDir: string | undefined; + private _traceModel: TraceModel; + private _server: http.Server; + private _resourceById: Map; + private _screenshotGenerator: ScreenshotGenerator | undefined; + + constructor(traceViewerDir: string | undefined, resourcesDir: string | undefined, traceModel: TraceModel, screenshotGenerator: ScreenshotGenerator | undefined) { + this._traceViewerDir = traceViewerDir; + this._resourcesDir = resourcesDir; + this._traceModel = traceModel; + this._screenshotGenerator = screenshotGenerator; + this._server = http.createServer(this._onRequest.bind(this)); + this._server.listen(); + + this._resourceById = new Map(); + for (const contextEntry of traceModel.contexts) { + for (const pageEntry of contextEntry.pages) { + for (const action of pageEntry.actions) + action.resources.forEach(r => this._resourceById.set(r.resourceId, r)); + pageEntry.resources.forEach(r => this._resourceById.set(r.resourceId, r)); + } + } + } + + private _urlPrefix() { + const address = this._server.address(); + return typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`; + } + + traceViewerUrl(relative: string) { + return this._urlPrefix() + '/traceviewer/' + relative; + } + + snapshotRootUrl() { + return this._urlPrefix() + '/snapshot/'; + } + + snapshotUrl(pageId: string, snapshotId?: string, timestamp?: number) { + if (snapshotId) + return this._urlPrefix() + `/snapshot/pageId/${pageId}/snapshotId/${snapshotId}/main`; + if (timestamp) + return this._urlPrefix() + `/snapshot/pageId/${pageId}/timestamp/${timestamp}/main`; + return 'data:text/html,Snapshot is not available'; + } + + private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { + // This server serves: + // - "/traceviewer/..." - our frontend; + // - "/sha1/" - trace resources; + // - "/tracemodel" - json with trace model; + // - "/resources/" - network resources from the trace; + // - "/file?filePath" - local files for sources tab; + // - "/action-preview/..." - lazily generated action previews; + // - "/snapshot/" - root for snapshot frame; + // - "/snapshot/pageId/..." - actual snapshot html; + // - "/service-worker.js" - service worker that intercepts snapshot resources + // and translates them into "/resources/". + + request.on('error', () => response.end()); + if (!request.url) + return response.end(); + + const url = new URL('http://localhost' + request.url); + // These two entry points do not require referrer check. + if (url.pathname.startsWith('/traceviewer/') && this._serveTraceViewer(request, response, url.pathname)) + return; + if (url.pathname === '/snapshot/' && this._serveSnapshotRoot(request, response)) + return; + + // Only serve the rest when referrer is present to avoid exposure. + const hasReferrer = request.headers['referer'] && request.headers['referer'].startsWith(this._urlPrefix()); + if (!hasReferrer) + return response.end(); + if (url.pathname.startsWith('/resources/') && this._serveResource(request, response, url.pathname)) + return; + if (url.pathname.startsWith('/sha1/') && this._serveSha1(request, response, url.pathname)) + return; + if (url.pathname.startsWith('/action-preview/') && this._serveActionPreview(request, response, url.pathname)) + return; + if (url.pathname === '/file' && this._serveFile(request, response, url.search)) + return; + if (url.pathname === '/service-worker.js' && this._serveServiceWorker(request, response)) + return; + if (url.pathname === '/tracemodel' && this._serveTraceModel(request, response)) + return; + + response.statusCode = 404; + response.end(); + } + + private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean { + response.statusCode = 200; + response.setHeader('Cache-Control', 'public, max-age=31536000'); + response.setHeader('Content-Type', 'text/html'); + response.end(` + + + + + `); + return true; + } + + private _serveServiceWorker(request: http.IncomingMessage, response: http.ServerResponse): boolean { + function serviceWorkerMain(self: any /* ServiceWorkerGlobalScope */, urlPrefix: string) { + let traceModel: TraceModel; + + function preprocessModel() { + for (const contextEntry of traceModel.contexts) { + contextEntry.resourcesByUrl = new Map(); + const appendResource = (event: trace.NetworkResourceTraceEvent) => { + let responseEvents = contextEntry.resourcesByUrl.get(event.url); + if (!responseEvents) { + responseEvents = []; + contextEntry.resourcesByUrl.set(event.url, responseEvents); + } + responseEvents.push(event); + }; + for (const pageEntry of contextEntry.pages) { + for (const action of pageEntry.actions) + action.resources.forEach(appendResource); + pageEntry.resources.forEach(appendResource); + } + } + } + + self.addEventListener('install', function(event: any) { + event.waitUntil(fetch('./tracemodel').then(async response => { + traceModel = await response.json(); + preprocessModel(); + })); + }); + + self.addEventListener('activate', function(event: any) { + event.waitUntil(self.clients.claim()); + }); + + function parseUrl(urlString: string): { pageId: string, frameId: string, timestamp?: number, snapshotId?: string } { + const url = new URL(urlString); + const parts = url.pathname.split('/'); + if (!parts[0]) + parts.shift(); + if (!parts[parts.length - 1]) + parts.pop(); + // snapshot/pageId//snapshotId// + // snapshot/pageId//timestamp// + if (parts.length !== 6 || parts[0] !== 'snapshot' || parts[1] !== 'pageId' || (parts[3] !== 'snapshotId' && parts[3] !== 'timestamp')) + throw new Error(`Unexpected url "${urlString}"`); + return { + pageId: parts[2], + frameId: parts[5] === 'main' ? '' : parts[5], + snapshotId: (parts[3] === 'snapshotId' ? parts[4] : undefined), + timestamp: (parts[3] === 'timestamp' ? +parts[4] : undefined), + }; + } + + function respond404(): Response { + return new Response(null, { status: 404 }); + } + + function respondNotAvailable(): Response { + return new Response('Snapshot is not available', { status: 200, headers: { 'Content-Type': 'text/html' } }); + } + + function removeHash(url: string) { + try { + const u = new URL(url); + u.hash = ''; + return u.toString(); + } catch (e) { + return url; + } + } + + async function doFetch(event: any /* FetchEvent */): Promise { + for (const prefix of ['/traceviewer/', '/sha1/', '/resources/', '/file?', '/action-preview/']) { + if (event.request.url.startsWith(urlPrefix + prefix)) + return fetch(event.request); + } + for (const exact of ['/tracemodel', '/service-worker.js', '/snapshot/']) { + if (event.request.url === urlPrefix + exact) + return fetch(event.request); + } + + const request = event.request; + let parsed; + if (request.mode === 'navigate') { + parsed = parseUrl(request.url); + } else { + const client = (await self.clients.get(event.clientId))!; + parsed = parseUrl(client.url); + } + + let contextEntry; + let pageEntry; + for (const c of traceModel.contexts) { + for (const p of c.pages) { + if (p.created.pageId === parsed.pageId) { + contextEntry = c; + pageEntry = p; + } + } + } + if (!contextEntry || !pageEntry) + return request.mode === 'navigate' ? respondNotAvailable() : respond404(); + + const lastSnapshotEvent = new Map(); + for (const [frameId, snapshots] of Object.entries(pageEntry.snapshotsByFrameId)) { + for (const snapshot of snapshots) { + const current = lastSnapshotEvent.get(frameId); + // Prefer snapshot with exact id. + const exactMatch = parsed.snapshotId && snapshot.snapshotId === parsed.snapshotId; + const currentExactMatch = current && parsed.snapshotId && current.snapshotId === parsed.snapshotId; + // If not available, prefer the latest snapshot before the timestamp. + const timestampMatch = parsed.timestamp && snapshot.timestamp <= parsed.timestamp; + if (exactMatch || (timestampMatch && !currentExactMatch)) + lastSnapshotEvent.set(frameId, snapshot); + } + } + + const snapshotEvent = lastSnapshotEvent.get(parsed.frameId); + if (!snapshotEvent) + return request.mode === 'navigate' ? respondNotAvailable() : respond404(); + + if (request.mode === 'navigate') + return new Response(snapshotEvent.snapshot.html, { status: 200, headers: { 'Content-Type': 'text/html' } }); + + let resource: trace.NetworkResourceTraceEvent | null = null; + const resourcesWithUrl = contextEntry.resourcesByUrl.get(removeHash(request.url)) || []; + for (const resourceEvent of resourcesWithUrl) { + if (resource && resourceEvent.frameId !== parsed.frameId) + continue; + resource = resourceEvent; + if (resourceEvent.frameId === parsed.frameId) + break; + } + if (!resource) + return respond404(); + const resourceOverride = snapshotEvent.snapshot.resourceOverrides.find(o => o.url === request.url); + const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined; + if (overrideSha1) + return fetch(`/resources/${resource.resourceId}/override/${overrideSha1}`); + return fetch(`/resources/${resource.resourceId}`); + } + + self.addEventListener('fetch', function(event: any) { + event.respondWith(doFetch(event)); + }); + } + + response.statusCode = 200; + response.setHeader('Cache-Control', 'public, max-age=31536000'); + response.setHeader('Content-Type', 'application/javascript'); + response.end(`(${serviceWorkerMain.toString()})(self, '${this._urlPrefix()}')`); + return true; + } + + private _serveTraceModel(request: http.IncomingMessage, response: http.ServerResponse): boolean { + response.statusCode = 200; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(this._traceModel)); + return true; + } + + private _serveResource(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean { + if (!this._resourcesDir) + return false; + + const parts = pathname.split('/'); + if (!parts[0]) + parts.shift(); + if (!parts[parts.length - 1]) + parts.pop(); + if (parts[0] !== 'resources') + return false; + + let resourceId; + let overrideSha1; + if (parts.length === 2) { + resourceId = parts[1]; + } else if (parts.length === 4 && parts[2] === 'override') { + resourceId = parts[1]; + overrideSha1 = parts[3]; + } else { + return false; + } + + const resource = this._resourceById.get(resourceId); + if (!resource) + return false; + const sha1 = overrideSha1 || resource.responseSha1; + try { + // console.log(`reading ${sha1} as ${resource.contentType}...`); + const content = fs.readFileSync(path.join(this._resourcesDir, sha1)); + response.statusCode = 200; + let contentType = resource.contentType; + const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(contentType); + if (isTextEncoding && !contentType.includes('charset')) + contentType = `${contentType}; charset=utf-8`; + response.setHeader('Content-Type', contentType); + for (const { name, value } of resource.responseHeaders) + response.setHeader(name, value); + + response.removeHeader('Content-Encoding'); + response.removeHeader('Access-Control-Allow-Origin'); + response.setHeader('Access-Control-Allow-Origin', '*'); + response.removeHeader('Content-Length'); + response.setHeader('Content-Length', content.byteLength); + response.end(content); + // console.log(`done`); + return true; + } catch (e) { + return false; + } + } + + private _serveActionPreview(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean { + if (!this._screenshotGenerator) + return false; + const fullPath = pathname.substring('/action-preview/'.length); + const actionId = fullPath.substring(0, fullPath.indexOf('.png')); + this._screenshotGenerator.generateScreenshot(actionId).then(body => { + if (!body) { + response.statusCode = 404; + response.end(); + } else { + response.statusCode = 200; + response.setHeader('Content-Type', 'image/png'); + response.setHeader('Content-Length', body.byteLength); + response.end(body); + } + }); + return true; + } + + private _serveSha1(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean { + if (!this._resourcesDir) + return false; + const parts = pathname.split('/'); + if (!parts[0]) + parts.shift(); + if (!parts[parts.length - 1]) + parts.pop(); + if (parts.length !== 2 || parts[0] !== 'sha1') + return false; + const sha1 = parts[1]; + return this._serveStaticFile(response, path.join(this._resourcesDir, sha1)); + } + + private _serveFile(request: http.IncomingMessage, response: http.ServerResponse, search: string): boolean { + if (search[0] !== '?') + return false; + return this._serveStaticFile(response, search.substring(1)); + } + + private _serveTraceViewer(request: http.IncomingMessage, response: http.ServerResponse, pathname: string): boolean { + if (!this._traceViewerDir) + return false; + const relativePath = pathname.substring('/traceviewer/'.length); + const absolutePath = path.join(this._traceViewerDir, ...relativePath.split('/')); + return this._serveStaticFile(response, absolutePath, { 'Service-Worker-Allowed': '/' }); + } + + private _serveStaticFile(response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean { + try { + const content = fs.readFileSync(absoluteFilePath); + response.statusCode = 200; + const contentType = extensionToMime[path.extname(absoluteFilePath).substring(1)] || 'application/octet-stream'; + response.setHeader('Content-Type', contentType); + response.setHeader('Content-Length', content.byteLength); + for (const [name, value] of Object.entries(headers || {})) + response.setHeader(name, value); + response.end(content); + return true; + } catch (e) { + return false; + } + } +} + +const extensionToMime: { [key: string]: string } = { + 'css': 'text/css', + 'html': 'text/html', + 'jpeg': 'image/jpeg', + 'jpg': 'image/jpeg', + 'js': 'application/javascript', + 'png': 'image/png', + 'ttf': 'font/ttf', + 'svg': 'image/svg+xml', + 'webp': 'image/webp', + 'woff': 'font/woff', + 'woff2': 'font/woff2', +}; diff --git a/src/cli/traceViewer/traceModel.ts b/src/cli/traceViewer/traceModel.ts index 264fcf594b..10c3046f7d 100644 --- a/src/cli/traceViewer/traceModel.ts +++ b/src/cli/traceViewer/traceModel.ts @@ -46,7 +46,7 @@ export type PageEntry = { actions: ActionEntry[]; interestingEvents: InterestingPageEvent[]; resources: trace.NetworkResourceTraceEvent[]; - snapshotsByFrameId: Map; + snapshotsByFrameId: { [key: string]: trace.FrameSnapshotTraceEvent[] }; } export type ActionEntry = { @@ -94,7 +94,7 @@ export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel actions: [], resources: [], interestingEvents: [], - snapshotsByFrameId: new Map(), + snapshotsByFrameId: {}, }; pageEntries.set(event.pageId, pageEntry); contextEntries.get(event.contextId)!.pages.push(pageEntry); @@ -115,7 +115,7 @@ export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel const action: ActionEntry = { actionId, action: event, - thumbnailUrl: `action-preview/${actionId}.png`, + thumbnailUrl: `/action-preview/${actionId}.png`, resources: pageEntry.resources, }; pageEntry.resources = []; @@ -148,9 +148,9 @@ export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel } case 'snapshot': { const pageEntry = pageEntries.get(event.pageId!)!; - if (!pageEntry.snapshotsByFrameId.has(event.frameId)) - pageEntry.snapshotsByFrameId.set(event.frameId, []); - pageEntry.snapshotsByFrameId.get(event.frameId)!.push(event); + if (!(event.frameId in pageEntry.snapshotsByFrameId)) + pageEntry.snapshotsByFrameId[event.frameId] = []; + pageEntry.snapshotsByFrameId[event.frameId]!.push(event); break; } } diff --git a/src/cli/traceViewer/traceViewer.ts b/src/cli/traceViewer/traceViewer.ts index 3d896501be..b3a168c62b 100644 --- a/src/cli/traceViewer/traceViewer.ts +++ b/src/cli/traceViewer/traceViewer.ts @@ -19,17 +19,15 @@ import * as path from 'path'; import * as playwright from '../../..'; import * as util from 'util'; import { ScreenshotGenerator } from './screenshotGenerator'; -import { SnapshotRouter } from './snapshotRouter'; import { readTraceFile, TraceModel } from './traceModel'; -import type { ActionTraceEvent, TraceEvent } from '../../trace/traceTypes'; +import type { TraceEvent } from '../../trace/traceTypes'; +import { SnapshotServer } from './snapshotServer'; const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); type TraceViewerDocument = { resourcesDir: string; model: TraceModel; - snapshotRouter: SnapshotRouter; - screenshotGenerator: ScreenshotGenerator; }; const emptyModel: TraceModel = { @@ -62,17 +60,12 @@ const emptyModel: TraceModel = { class TraceViewer { private _document: TraceViewerDocument | undefined; - constructor() { - } - 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), }; for (const name of fs.readdirSync(traceDir)) { @@ -87,78 +80,14 @@ class TraceViewer { async show() { const browser = await playwright.chromium.launch({ headless: false }); + const server = await SnapshotServer.create( + path.join(__dirname, 'web'), + this._document ? this._document.resourcesDir : undefined, + this._document ? this._document.model : emptyModel, + this._document ? new ScreenshotGenerator(this._document.resourcesDir, this._document.model) : undefined); const uiPage = await browser.newPage({ viewport: null }); uiPage.on('close', () => process.exit(0)); - await uiPage.exposeBinding('readFile', async (_, path: string) => { - return fs.readFileSync(path).toString(); - }); - await uiPage.exposeBinding('readResource', async (_, sha1: string) => { - if (!this._document) - return; - - return fs.readFileSync(path.join(this._document.resourcesDir, sha1)).toString('base64'); - }); - await uiPage.exposeBinding('renderSnapshot', async (_, arg: { action: ActionTraceEvent, snapshot: { snapshotId?: string, snapshotTime?: number } }) => { - const { action, snapshot } = arg; - if (!this._document) - return; - try { - const contextEntry = this._document.model.contexts.find(entry => entry.created.contextId === action.contextId)!; - const pageEntry = contextEntry.pages.find(entry => entry.created.pageId === action.pageId)!; - const pageUrl = await this._document.snapshotRouter.selectSnapshot(contextEntry, pageEntry, snapshot.snapshotId, snapshot.snapshotTime); - - // TODO: fix Playwright bug where frame.name is lost (empty). - const snapshotFrame = uiPage.frames()[1]; - try { - await snapshotFrame.goto(pageUrl); - } catch (e) { - if (!e.message.includes('frame was detached')) - console.error(e); - return; - } - const element = await snapshotFrame.$(action.selector || '*[__playwright_target__]').catch(e => undefined); - if (element) { - await element.evaluate(e => { - e.style.backgroundColor = '#ff69b460'; - }); - } - } catch (e) { - console.log(e); // eslint-disable-line no-console - } - }); - await uiPage.exposeBinding('getTraceModel', () => this._document ? this._document.model : emptyModel); - await uiPage.route('**/*', (route, request) => { - if (request.frame().parentFrame() && this._document) { - this._document.snapshotRouter.route(route); - return; - } - try { - const url = new URL(request.url()); - 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._document.screenshotGenerator.generateScreenshot(actionId).then(body => { - if (body) - route.fulfill({ contentType: 'image/png', body }); - else - route.fulfill({ status: 404 }); - }); - return; - } - const filePath = path.join(__dirname, 'web', url.pathname.substring(1)); - const body = fs.readFileSync(filePath); - route.fulfill({ - contentType: extensionToMime[path.extname(url.pathname).substring(1)] || 'text/plain', - body, - }); - } catch (e) { - console.log(e); // eslint-disable-line no-console - route.fulfill({ - status: 404 - }); - } - }); - await uiPage.goto('http://trace-viewer/index.html'); + await uiPage.goto(server.traceViewerUrl('index.html')); } } @@ -168,17 +97,3 @@ export async function showTraceViewer(traceDir: string) { await traceViewer.load(traceDir); await traceViewer.show(); } - -const extensionToMime: { [key: string]: string } = { - 'css': 'text/css', - 'html': 'text/html', - 'jpeg': 'image/jpeg', - 'jpg': 'image/jpeg', - 'js': 'application/javascript', - 'png': 'image/png', - 'ttf': 'font/ttf', - 'svg': 'image/svg+xml', - 'webp': 'image/webp', - 'woff': 'font/woff', - 'woff2': 'font/woff2', -}; diff --git a/src/cli/traceViewer/web/index.tsx b/src/cli/traceViewer/web/index.tsx index 0590c72be0..c2c7ccf418 100644 --- a/src/cli/traceViewer/web/index.tsx +++ b/src/cli/traceViewer/web/index.tsx @@ -14,24 +14,17 @@ * limitations under the License. */ -import { TraceModel, VideoMetaInfo, trace } from '../traceModel'; import './third_party/vscode/codicon.css'; import { Workbench } from './ui/workbench'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { applyTheme } from './theme'; -declare global { - interface Window { - getTraceModel(): Promise; - readFile(filePath: string): Promise; - readResource(sha1: string): Promise; - renderSnapshot(arg: { action: trace.ActionTraceEvent, snapshot: { snapshotId?: string, snapshotTime?: number } }): void; - } -} - (async () => { + navigator.serviceWorker.register('/service-worker.js'); + if (!navigator.serviceWorker.controller) + await new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve); applyTheme(); - const traceModel = await window.getTraceModel(); + const traceModel = await fetch('/tracemodel').then(response => response.json()); ReactDOM.render(, document.querySelector('#root')); })(); diff --git a/src/cli/traceViewer/web/ui/networkResourceDetails.tsx b/src/cli/traceViewer/web/ui/networkResourceDetails.tsx index 554e564a35..1e9f28c882 100644 --- a/src/cli/traceViewer/web/ui/networkResourceDetails.tsx +++ b/src/cli/traceViewer/web/ui/networkResourceDetails.tsx @@ -38,12 +38,12 @@ export const NetworkResourceDetails: React.FunctionComponent<{ React.useEffect(() => { const readResources = async () => { if (resource.requestSha1 !== 'none') { - const requestResource = await window.readResource(resource.requestSha1); + const requestResource = await fetch(`/sha1/${resource.requestSha1}`).then(response => response.text()); setRequestBody(requestResource); } if (resource.responseSha1 !== 'none') { - const responseResource = await window.readResource(resource.responseSha1); + const responseResource = await fetch(`/sha1/${resource.responseSha1}`).then(response => response.text()); setResponseBody(responseResource); } }; @@ -55,7 +55,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{ if (body === null) return 'Loading...'; - const bodyStr = atob(body); + const bodyStr = body; if (bodyStr === '') return ''; diff --git a/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx b/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx index 0d954439cf..f5846c2e2c 100644 --- a/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx +++ b/src/cli/traceViewer/web/ui/propertiesTabbedPane.tsx @@ -75,6 +75,9 @@ const SnapshotTab: React.FunctionComponent<{ boundaries: Boundaries, }> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => { const [measure, ref] = useMeasure(); + const [snapshotIndex, setSnapshotIndex] = React.useState(0); + const origin = location.href.substring(0, location.href.indexOf(location.pathname)); + const snapshotIframeUrl = origin + '/snapshot/'; let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = []; snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice(); @@ -82,29 +85,36 @@ const SnapshotTab: React.FunctionComponent<{ snapshots.unshift({ name: 'before', snapshotTime: actionEntry ? actionEntry.action.startTime : 0 }); if (snapshots[snapshots.length - 1].name !== 'after') snapshots.push({ name: 'after', snapshotTime: actionEntry ? actionEntry.action.endTime : 0 }); - if (selectedTime) - snapshots = [{ name: msToString(selectedTime - boundaries.minimum), snapshotTime: selectedTime }]; - - const [snapshotIndex, setSnapshotIndex] = React.useState(0); - React.useEffect(() => { - setSnapshotIndex(0); - }, [selectedTime]); const iframeRef = React.createRef(); React.useEffect(() => { - if (iframeRef.current && !actionEntry) - iframeRef.current.src = 'about:blank'; - }, [actionEntry, iframeRef]); + if (!actionEntry || !iframeRef.current) + return; - React.useEffect(() => { - if (actionEntry && snapshots[snapshotIndex]) - (window as any).renderSnapshot({ action: actionEntry.action, snapshot: snapshots[snapshotIndex] }); + let snapshotUrl = 'data:text/html,Snapshot is not available'; + if (selectedTime) { + snapshotUrl = origin + `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${selectedTime}/main`; + } else { + const snapshot = snapshots[snapshotIndex]; + if (snapshot && snapshot.snapshotTime) + snapshotUrl = origin + `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`; + else if (snapshot && snapshot.snapshotId) + snapshotUrl = origin + `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`; + } + + try { + (iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl); + } catch (e) { + } }, [actionEntry, snapshotIndex, selectedTime]); const scale = Math.min(measure.width / snapshotSize.width, measure.height / snapshotSize.height); return
{ - snapshots.map((snapshot, index) => { + selectedTime &&
+ {msToString(selectedTime - boundaries.minimum)} +
+ }{!selectedTime && snapshots.map((snapshot, index) => { return
- +
; diff --git a/src/cli/traceViewer/web/ui/sourceTab.tsx b/src/cli/traceViewer/web/ui/sourceTab.tsx index fd9d7d9e79..4f8a28bf5d 100644 --- a/src/cli/traceViewer/web/ui/sourceTab.tsx +++ b/src/cli/traceViewer/web/ui/sourceTab.tsx @@ -94,7 +94,7 @@ export const SourceTab: React.FunctionComponent<{ } else { const filePath = stackInfo.frames[selectedFrame].filePath; if (!stackInfo.fileContent.has(filePath)) - stackInfo.fileContent.set(filePath, await window.readFile(filePath).catch(e => ``)); + stackInfo.fileContent.set(filePath, await fetch(`/file?${filePath}`).then(response => response.text()).catch(e => ``)); value = stackInfo.fileContent.get(filePath)!; } const result = []; diff --git a/src/cli/traceViewer/web/ui/timeline.tsx b/src/cli/traceViewer/web/ui/timeline.tsx index 22a54b971c..90a2d3d63d 100644 --- a/src/cli/traceViewer/web/ui/timeline.tsx +++ b/src/cli/traceViewer/web/ui/timeline.tsx @@ -39,9 +39,8 @@ export const Timeline: React.FunctionComponent<{ selectedAction: ActionEntry | undefined, highlightedAction: ActionEntry | undefined, onSelected: (action: ActionEntry) => void, - onHighlighted: (action: ActionEntry | undefined) => void, - onTimeSelected: (time: number) => void, -}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onHighlighted, onTimeSelected }) => { + onTimeSelected: (time: number | undefined) => void, +}> = ({ context, boundaries, selectedAction, highlightedAction, onSelected, onTimeSelected }) => { const [measure, ref] = useMeasure(); const [previewX, setPreviewX] = React.useState(); const [hoveredBar, setHoveredBar] = React.useState(); @@ -140,13 +139,13 @@ export const Timeline: React.FunctionComponent<{ if (ref.current) { const x = event.clientX - ref.current.getBoundingClientRect().left; setPreviewX(x); - const bar = findHoveredBar(x); - setHoveredBar(bar); - onHighlighted(bar && bar.entry ? bar.entry : undefined); + onTimeSelected(positionToTime(measure.width, boundaries, x)); + setHoveredBar(findHoveredBar(x)); } }; const onMouseLeave = () => { setPreviewX(undefined); + onTimeSelected(undefined); }; const onActionClick = (event: React.MouseEvent) => { if (ref.current) { @@ -242,4 +241,3 @@ function timeToPosition(clientWidth: number, boundaries: Boundaries, time: numbe function positionToTime(clientWidth: number, boundaries: Boundaries, x: number): number { return x / clientWidth * (boundaries.maximum - boundaries.minimum) + boundaries.minimum; } - diff --git a/src/cli/traceViewer/web/ui/workbench.tsx b/src/cli/traceViewer/web/ui/workbench.tsx index 6c91c4b4df..bf7ddf4877 100644 --- a/src/cli/traceViewer/web/ui/workbench.tsx +++ b/src/cli/traceViewer/web/ui/workbench.tsx @@ -63,11 +63,7 @@ export const Workbench: React.FunctionComponent<{ boundaries={boundaries} selectedAction={selectedAction} highlightedAction={highlightedAction} - onSelected={action => { - setSelectedAction(action); - setSelectedTime(undefined); - }} - onHighlighted={action => setHighlightedAction(action)} + onSelected={action => setSelectedAction(action)} onTimeSelected={time => setSelectedTime(time)} /> diff --git a/src/trace/snapshotter.ts b/src/trace/snapshotter.ts index c99b3fec8f..4b72603d5a 100644 --- a/src/trace/snapshotter.ts +++ b/src/trace/snapshotter.ts @@ -45,7 +45,7 @@ export type SnapshotterBlob = { export interface SnapshotterDelegate { onBlob(blob: SnapshotterBlob): void; onResource(resource: SnapshotterResource): void; - onFrameSnapshot(frame: Frame, snapshot: FrameSnapshot, snapshotId?: string): void; + onFrameSnapshot(frame: Frame, frameUrl: string, snapshot: FrameSnapshot, snapshotId?: string): void; pageId(page: Page): string; } @@ -65,7 +65,6 @@ export class Snapshotter { html: data.html, viewport: data.viewport, resourceOverrides: [], - url: data.url, }; for (const { url, content } of data.resourceOverrides) { const buffer = Buffer.from(content); @@ -73,7 +72,7 @@ export class Snapshotter { this._delegate.onBlob({ sha1, buffer }); snapshot.resourceOverrides.push({ url, sha1 }); } - this._delegate.onFrameSnapshot(source.frame, snapshot, data.snapshotId); + this._delegate.onFrameSnapshot(source.frame, data.url, snapshot, data.snapshotId); }); this._context._doAddInitScript('(' + frameSnapshotStreamer.toString() + ')()'); } diff --git a/src/trace/snapshotterInjected.ts b/src/trace/snapshotterInjected.ts index 6aa15570ed..2cf212bc08 100644 --- a/src/trace/snapshotterInjected.ts +++ b/src/trace/snapshotterInjected.ts @@ -226,10 +226,8 @@ export function frameSnapshotStreamer() { // TODO: handle srcdoc? const frameId = element.getAttribute(kSnapshotFrameIdAttribute); if (frameId) { - let protocol = win.location.protocol; - if (!protocol.startsWith('http')) - protocol = 'http:'; - value = protocol + '//' + frameId + '/'; + needScript = true; + value = frameId; } else { value = 'data:text/html,Snapshot is not available'; } @@ -321,22 +319,42 @@ export function frameSnapshotStreamer() { }; function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) { - const scrollTops = document.querySelectorAll(`[${scrollTopAttribute}]`); - const scrollLefts = document.querySelectorAll(`[${scrollLeftAttribute}]`); - for (const element of document.querySelectorAll(`template[${shadowAttribute}]`)) { - const template = element as HTMLTemplateElement; - const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' }); - shadowRoot.appendChild(template.content); - template.remove(); - } - const onDOMContentLoaded = () => { - window.removeEventListener('DOMContentLoaded', onDOMContentLoaded); - for (const element of scrollTops) - element.scrollTop = +element.getAttribute(scrollTopAttribute)!; - for (const element of scrollLefts) - element.scrollLeft = +element.getAttribute(scrollLeftAttribute)!; + const scrollTops: Element[] = []; + const scrollLefts: Element[] = []; + + const visit = (root: Document | ShadowRoot) => { + for (const e of root.querySelectorAll(`[${scrollTopAttribute}]`)) + scrollTops.push(e); + for (const e of root.querySelectorAll(`[${scrollLeftAttribute}]`)) + scrollLefts.push(e); + + for (const iframe of root.querySelectorAll('iframe')) { + const src = iframe.getAttribute('src') || ''; + if (src.startsWith('data:text/html')) + continue; + const index = location.pathname.lastIndexOf('/'); + if (index === -1) + continue; + const pathname = location.pathname.substring(0, index + 1) + src; + const href = location.href.substring(0, location.href.indexOf(location.pathname)) + pathname; + iframe.setAttribute('src', href); + } + + for (const element of root.querySelectorAll(`template[${shadowAttribute}]`)) { + const template = element as HTMLTemplateElement; + const shadowRoot = template.parentElement!.attachShadow({ mode: 'open' }); + shadowRoot.appendChild(template.content); + template.remove(); + visit(shadowRoot); + } }; - window.addEventListener('DOMContentLoaded', onDOMContentLoaded); + visit(document); + + for (const element of scrollTops) + element.scrollTop = +element.getAttribute(scrollTopAttribute)!; + for (const element of scrollLefts) + element.scrollLeft = +element.getAttribute(scrollLeftAttribute)!; + const onLoad = () => { window.removeEventListener('load', onLoad); for (const element of scrollTops) { diff --git a/src/trace/traceTypes.ts b/src/trace/traceTypes.ts index 44f58a827b..cfea1ba427 100644 --- a/src/trace/traceTypes.ts +++ b/src/trace/traceTypes.ts @@ -36,6 +36,7 @@ export type NetworkResourceTraceEvent = { contextId: string, pageId: string, frameId: string, + resourceId: string, url: string, contentType: string, responseHeaders: { name: string, value: string }[], @@ -124,7 +125,7 @@ export type FrameSnapshotTraceEvent = { contextId: string, pageId: string, frameId: string, // Empty means main frame. - sha1: string, + snapshot: FrameSnapshot, frameUrl: string, snapshotId?: string, }; @@ -148,5 +149,4 @@ export type FrameSnapshot = { html: string, resourceOverrides: { url: string, sha1: string }[], viewport: { width: number, height: number }, - url: string, }; diff --git a/src/trace/tracer.ts b/src/trace/tracer.ts index b21c334319..f476817702 100644 --- a/src/trace/tracer.ts +++ b/src/trace/tracer.ts @@ -20,7 +20,7 @@ import * as trace from './traceTypes'; import * as path from 'path'; import * as util from 'util'; import * as fs from 'fs'; -import { calculateSha1, createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../utils/utils'; +import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../utils/utils'; import { Page } from '../server/page'; import { Snapshotter } from './snapshotter'; import { helper, RegisteredListener } from '../server/helper'; @@ -117,6 +117,7 @@ class ContextTracer implements SnapshotterDelegate, ActionListener { contextId: this._contextId, pageId: resource.pageId, frameId: resource.frameId, + resourceId: 'resource@' + createGuid(), url: resource.url, contentType: resource.contentType, responseHeaders: resource.responseHeaders, @@ -129,18 +130,15 @@ class ContextTracer implements SnapshotterDelegate, ActionListener { this._appendTraceEvent(event); } - onFrameSnapshot(frame: Frame, snapshot: trace.FrameSnapshot, snapshotId?: string): void { - const buffer = Buffer.from(JSON.stringify(snapshot)); - const sha1 = calculateSha1(buffer); - this._writeArtifact(sha1, buffer); + onFrameSnapshot(frame: Frame, frameUrl: string, snapshot: trace.FrameSnapshot, snapshotId?: string): void { const event: trace.FrameSnapshotTraceEvent = { timestamp: monotonicTime(), type: 'snapshot', contextId: this._contextId, pageId: this.pageId(frame._page), frameId: frame._page.mainFrame() === frame ? '' : frame._id, - sha1, - frameUrl: snapshot.url, + snapshot: snapshot, + frameUrl, snapshotId, }; this._appendTraceEvent(event); diff --git a/test/trace.spec.ts b/test/trace.spec.ts index 9ac1554449..706e6d5693 100644 --- a/test/trace.spec.ts +++ b/test/trace.spec.ts @@ -61,8 +61,7 @@ it('should record trace', async ({browser, testInfo, server}) => { expect(clickEvent.snapshots.length).toBe(2); const snapshotId = clickEvent.snapshots[0].snapshotId; const snapshotEvent = traceEvents.find(event => event.type === 'snapshot' && event.snapshotId === snapshotId) as trace.FrameSnapshotTraceEvent; - - expect(fs.existsSync(path.join(traceDir, 'resources', snapshotEvent.sha1))).toBe(true); + expect(snapshotEvent).toBeTruthy(); }); it('should record trace with POST', async ({browser, testInfo, server}) => {