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:
parent
e915e51ea9
commit
ce43e730f4
|
|
@ -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__]');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() + ')()');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue