mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	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();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										463
									
								
								src/cli/traceViewer/snapshotServer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										463
									
								
								src/cli/traceViewer/snapshotServer.ts
									
									
									
									
									
										Normal 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',
 | 
			
		||||
};
 | 
			
		||||
@ -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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user