feat(traceviewer): use http server instead of interception (#5195)

This introduces an http server that serves our frontend and our snapshots. There is more work to untangle the big server into a few modules.

This change allows us:
- Maybe eventually serve the trace viewer as a web page.
- Rely on browser caches for fast snapshot rendering. This PR also adds "snapshot on hover" feature, subject to change.
This commit is contained in:
Dmitry Gozman 2021-01-27 19:42:51 -08:00 committed by GitHub
parent e915e51ea9
commit ce43e730f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 578 additions and 367 deletions

View File

@ -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<playwright.Browser>;
private _serverPromise: Promise<SnapshotServer>;
private _traceModel: TraceModel;
private _rendering = new Map<ActionEntry, Promise<Buffer | undefined>>();
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<Buffer | undefined> {
@ -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__]');

View File

@ -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<string>();
private _resourcesDir: string;
private _snapshotFrameIdToSnapshot = new Map<string, trace.FrameSnapshot>();
private _pageUrl = '';
private _frameToSnapshotFrameId = new Map<Frame, string>();
constructor(resourcesDir: string) {
this._resourcesDir = resourcesDir;
}
// Returns the url to navigate to.
async selectSnapshot(contextEntry: ContextEntry, pageEntry: PageEntry, snapshotId?: string, timestamp?: number): Promise<string> {
this._contextEntry = contextEntry;
if (!snapshotId && !timestamp)
return 'data:text/html,Snapshot is not available';
const lastSnapshotEvent = new Map<string, trace.FrameSnapshotTraceEvent>();
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();
}

View File

@ -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<SnapshotServer> {
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<string, trace.NetworkResourceTraceEvent>;
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/<sha1>" - trace resources;
// - "/tracemodel" - json with trace model;
// - "/resources/<resourceId>" - 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/<resourceId>".
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(`
<style>
html, body {
margin: 0;
padding: 0;
}
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
</style>
<body>
<script>
let current = document.createElement('iframe');
document.body.appendChild(current);
let next = document.createElement('iframe');
document.body.appendChild(next);
next.style.visibility = 'hidden';
let showPromise = Promise.resolve();
let nextUrl;
window.showSnapshot = url => {
if (!nextUrl) {
showPromise = showPromise.then(async () => {
const url = nextUrl;
nextUrl = undefined;
const loaded = new Promise(f => next.onload = f);
next.src = url;
await loaded;
let temp = current;
current = next;
next = temp;
current.style.visibility = 'visible';
next.style.visibility = 'hidden';
});
}
nextUrl = url;
return showPromise;
};
</script>
</body>
`);
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/<pageId>/snapshotId/<snapshotId>/<frameId>
// snapshot/pageId/<pageId>/timestamp/<timestamp>/<frameId>
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('<body>Snapshot is not available</body>', { 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<Response> {
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<string, trace.FrameSnapshotTraceEvent>();
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',
};

View File

@ -46,7 +46,7 @@ export type PageEntry = {
actions: ActionEntry[];
interestingEvents: InterestingPageEvent[];
resources: trace.NetworkResourceTraceEvent[];
snapshotsByFrameId: Map<string, trace.FrameSnapshotTraceEvent[]>;
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;
}
}

View File

@ -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',
};

View File

@ -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<TraceModel>;
readFile(filePath: string): Promise<string>;
readResource(sha1: string): Promise<string>;
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(<Workbench traceModel={traceModel} />, document.querySelector('#root'));
})();

View File

@ -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 '<Empty>';

View File

@ -75,6 +75,9 @@ const SnapshotTab: React.FunctionComponent<{
boundaries: Boundaries,
}> = ({ actionEntry, snapshotSize, selectedTime, boundaries }) => {
const [measure, ref] = useMeasure<HTMLDivElement>();
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<HTMLIFrameElement>();
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 <div className='snapshot-tab'>
<div className='snapshot-controls'>{
snapshots.map((snapshot, index) => {
selectedTime && <div key='selectedTime' className='snapshot-toggle'>
{msToString(selectedTime - boundaries.minimum)}
</div>
}{!selectedTime && snapshots.map((snapshot, index) => {
return <div
key={snapshot.name}
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
@ -119,7 +129,7 @@ const SnapshotTab: React.FunctionComponent<{
height: snapshotSize.height + 'px',
transform: `translate(${-snapshotSize.width * (1 - scale) / 2}px, ${-snapshotSize.height * (1 - scale) / 2}px) scale(${scale})`,
}}>
<iframe ref={iframeRef} id='snapshot' name='snapshot'></iframe>
<iframe ref={iframeRef} id='snapshot' name='snapshot' src={snapshotIframeUrl}></iframe>
</div>
</div>
</div>;

View File

@ -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 => `<Unable to read "${filePath}">`));
stackInfo.fileContent.set(filePath, await fetch(`/file?${filePath}`).then(response => response.text()).catch(e => `<Unable to read "${filePath}">`));
value = stackInfo.fileContent.get(filePath)!;
}
const result = [];

View File

@ -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<HTMLDivElement>();
const [previewX, setPreviewX] = React.useState<number | undefined>();
const [hoveredBar, setHoveredBar] = React.useState<TimelineBar | undefined>();
@ -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;
}

View File

@ -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)}
/>
</div>

View File

@ -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() + ')()');
}

View File

@ -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,<body>Snapshot is not available</body>';
}
@ -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) {

View File

@ -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,
};

View File

@ -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);

View File

@ -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}) => {