chore: refactor trace viewer to reuse snapshot storage (#5756)

This commit is contained in:
Pavel Feldman 2021-03-08 19:49:57 -08:00 committed by GitHub
parent 659d3c3b6f
commit 1a94ea5f6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 446 additions and 626 deletions

View file

@ -24,7 +24,7 @@ import { Page } from './page';
import * as types from './types';
import { BrowserContext } from './browserContext';
import { Progress, ProgressController } from './progress';
import { assert, makeWaitForNextTask } from '../utils/utils';
import { assert, createGuid, makeWaitForNextTask } from '../utils/utils';
import { debugLogger } from '../utils/debugLogger';
import { CallMetadata, SdkObject } from './instrumentation';
import { ElementStateWithoutStable } from './injected/injectedScript';
@ -415,7 +415,7 @@ export class Frame extends SdkObject {
constructor(page: Page, id: string, parentFrame: Frame | null) {
super(page);
this.uniqueId = parentFrame ? `frame@${page.uniqueId}/${id}` : page.uniqueId;
this.uniqueId = parentFrame ? `frame@${createGuid()}` : page.uniqueId;
this.attribution.frame = this;
this._id = id;
this._page = page;

View file

@ -14,24 +14,20 @@
* limitations under the License.
*/
import { EventEmitter } from 'events';
import { HttpServer } from '../../utils/httpServer';
import { BrowserContext } from '../browserContext';
import { helper } from '../helper';
import { Page } from '../page';
import { ContextResources, FrameSnapshot } from './snapshot';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { SnapshotRenderer } from './snapshotRenderer';
import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter';
import { SnapshotServer } from './snapshotServer';
import { BaseSnapshotStorage } from './snapshotStorage';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
const kSnapshotInterval = 25;
export class InMemorySnapshotter extends EventEmitter implements SnapshotStorage, SnapshotterDelegate {
export class InMemorySnapshotter extends BaseSnapshotStorage implements SnapshotterDelegate {
private _blobs = new Map<string, Buffer>();
private _resources = new Map<string, SnapshotterResource>();
private _frameSnapshots = new Map<string, FrameSnapshot[]>();
private _snapshots = new Map<string, SnapshotRenderer>();
private _contextResources: ContextResources = new Map();
private _server: HttpServer;
private _snapshotter: Snapshotter;
@ -56,14 +52,14 @@ export class InMemorySnapshotter extends EventEmitter implements SnapshotStorage
await this._server.stop();
}
async captureSnapshot(page: Page, snapshotId: string): Promise<SnapshotRenderer> {
if (this._snapshots.has(snapshotId))
throw new Error('Duplicate snapshotId: ' + snapshotId);
async captureSnapshot(page: Page, snapshotName: string): Promise<SnapshotRenderer> {
if (this._frameSnapshots.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + snapshotName);
this._snapshotter.captureSnapshot(page, snapshotId);
this._snapshotter.captureSnapshot(page, snapshotName);
return new Promise<SnapshotRenderer>(fulfill => {
const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => {
if (renderer.snapshotId === snapshotId) {
if (renderer.snapshotName === snapshotName) {
helper.removeEventListeners([listener]);
fulfill(renderer);
}
@ -79,41 +75,15 @@ export class InMemorySnapshotter extends EventEmitter implements SnapshotStorage
this._blobs.set(blob.sha1, blob.buffer);
}
onResource(resource: SnapshotterResource): void {
this._resources.set(resource.resourceId, resource);
let resources = this._contextResources.get(resource.url);
if (!resources) {
resources = [];
this._contextResources.set(resource.url, resources);
}
resources.push({ frameId: resource.frameId, resourceId: resource.resourceId });
onResourceSnapshot(resource: ResourceSnapshot): void {
this.addResource(resource);
}
onFrameSnapshot(snapshot: FrameSnapshot): void {
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
if (!frameSnapshots) {
frameSnapshots = [];
this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
}
frameSnapshots.push(snapshot);
const renderer = new SnapshotRenderer(new Map(this._contextResources), frameSnapshots, frameSnapshots.length - 1);
this._snapshots.set(snapshot.snapshotId, renderer);
this.emit('snapshot', renderer);
this.addFrameSnapshot(snapshot);
}
resourceContent(sha1: string): Buffer | undefined {
return this._blobs.get(sha1);
}
resourceById(resourceId: string): NetworkResponse | undefined {
return this._resources.get(resourceId)!;
}
snapshotById(snapshotId: string): SnapshotRenderer | undefined {
return this._snapshots.get(snapshotId);
}
frameSnapshots(frameId: string): FrameSnapshot[] {
return this._frameSnapshots.get(frameId) || [];
}
}

View file

@ -0,0 +1,80 @@
/**
* 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 { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import util from 'util';
import { BrowserContext } from '../browserContext';
import { Page } from '../page';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
const fsMkdirAsync = util.promisify(fs.mkdir.bind(fs));
const kSnapshotInterval = 100;
export class PersistentSnapshotter extends EventEmitter implements SnapshotterDelegate {
private _snapshotter: Snapshotter;
private _resourcesDir: string;
private _writeArtifactChain = Promise.resolve();
private _networkTrace: string;
private _snapshotTrace: string;
constructor(context: BrowserContext, tracePrefix: string, resourcesDir: string) {
super();
this._resourcesDir = resourcesDir;
this._networkTrace = tracePrefix + '-network.trace';
this._snapshotTrace = tracePrefix + '-dom.trace';
this._snapshotter = new Snapshotter(context, this);
}
async start(): Promise<void> {
await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {});
await this._snapshotter.initialize();
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
}
async dispose() {
this._snapshotter.dispose();
await this._writeArtifactChain;
}
captureSnapshot(page: Page, snapshotName: string) {
this._snapshotter.captureSnapshot(page, snapshotName);
}
onBlob(blob: SnapshotterBlob): void {
this._writeArtifactChain = this._writeArtifactChain.then(async () => {
await fsWriteFileAsync(path.join(this._resourcesDir, blob.sha1), blob.buffer);
});
}
onResourceSnapshot(resource: ResourceSnapshot): void {
this._writeArtifactChain = this._writeArtifactChain.then(async () => {
await fsAppendFileAsync(this._networkTrace, JSON.stringify(resource) + '\n');
});
}
onFrameSnapshot(snapshot: FrameSnapshot): void {
this._writeArtifactChain = this._writeArtifactChain.then(async () => {
await fsAppendFileAsync(this._snapshotTrace, JSON.stringify(snapshot) + '\n');
});
}
}

View file

@ -14,19 +14,23 @@
* limitations under the License.
*/
import { ContextResources, FrameSnapshot, NodeSnapshot, RenderedFrameSnapshot } from './snapshot';
import { ContextResources, FrameSnapshot, NodeSnapshot, RenderedFrameSnapshot } from './snapshotTypes';
export class SnapshotRenderer {
private _snapshots: FrameSnapshot[];
private _index: number;
private _contextResources: ContextResources;
readonly snapshotId: string;
readonly snapshotName: string | undefined;
constructor(contextResources: ContextResources, snapshots: FrameSnapshot[], index: number) {
this._contextResources = contextResources;
this._snapshots = snapshots;
this._index = index;
this.snapshotId = snapshots[index].snapshotId;
this.snapshotName = snapshots[index].snapshotName;
}
snapshot(): FrameSnapshot {
return this._snapshots[this._index];
}
render(): RenderedFrameSnapshot {
@ -69,7 +73,7 @@ export class SnapshotRenderer {
let html = visit(snapshot.html, this._index);
if (snapshot.doctype)
html = `<!DOCTYPE ${snapshot.doctype}>` + html;
html += `<script>${snapshotScript}</script>`;
html += `<script>${snapshotScript()}</script>`;
const resources: { [key: string]: { resourceId: string, sha1?: string } } = {};
for (const [url, contextResources] of this._contextResources) {
@ -113,7 +117,7 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
return (snapshot as any)._nodes;
}
export function snapshotScript() {
function snapshotScript() {
function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) {
const scrollTops: Element[] = [];
const scrollLefts: Element[] = [];
@ -126,17 +130,13 @@ export function snapshotScript() {
scrollLefts.push(e);
for (const iframe of root.querySelectorAll('iframe')) {
const src = iframe.getAttribute('src') || '';
if (src.startsWith('data:text/html'))
continue;
// Rewrite iframes to use snapshot url (relative to window.location)
// instead of begin relative to the <base> tag.
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);
const src = iframe.getAttribute('src');
if (!src) {
iframe.setAttribute('src', 'data:text/html,<body>Snapshot is not available</body>');
} else {
// Append query parameters to inherit ?name= or ?time= values from parent.
iframe.setAttribute('src', window.location.origin + src + window.location.search);
}
}
for (const element of root.querySelectorAll(`template[${shadowAttribute}]`)) {

View file

@ -16,21 +16,9 @@
import * as http from 'http';
import querystring from 'querystring';
import { SnapshotRenderer } from './snapshotRenderer';
import { HttpServer } from '../../utils/httpServer';
import type { RenderedFrameSnapshot } from './snapshot';
export type NetworkResponse = {
contentType: string;
responseHeaders: { name: string, value: string }[];
responseSha1: string;
};
export interface SnapshotStorage {
resourceContent(sha1: string): Buffer | undefined;
resourceById(resourceId: string): NetworkResponse | undefined;
snapshotById(snapshotId: string): SnapshotRenderer | undefined;
}
import type { RenderedFrameSnapshot } from './snapshotTypes';
import { SnapshotStorage } from './snapshotStorage';
export class SnapshotServer {
private _snapshotStorage: SnapshotStorage;
@ -38,9 +26,7 @@ export class SnapshotServer {
constructor(server: HttpServer, snapshotStorage: SnapshotStorage) {
this._snapshotStorage = snapshotStorage;
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this));
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
server.routePrefix('/snapshot/', this._serveSnapshot.bind(this));
server.routePrefix('/resources/', this._serveResource.bind(this));
}
@ -91,7 +77,7 @@ export class SnapshotServer {
next.src = url;
};
window.addEventListener('message', event => {
window.showSnapshot(window.location.href + event.data.snapshotId);
window.showSnapshot(window.location.href + event.data.snapshotUrl);
}, false);
}
</script>
@ -135,24 +121,20 @@ export class SnapshotServer {
if (pathname === '/snapshot/service-worker.js' || pathname === '/snapshot/')
return fetch(event.request);
let snapshotId: string;
const snapshotUrl = request.mode === 'navigate' ?
request.url : (await self.clients.get(event.clientId))!.url;
if (request.mode === 'navigate') {
snapshotId = pathname.substring('/snapshot/'.length);
} else {
const client = (await self.clients.get(event.clientId))!;
snapshotId = new URL(client.url).pathname.substring('/snapshot/'.length);
}
if (request.mode === 'navigate') {
const htmlResponse = await fetch(`/snapshot-data?snapshotId=${snapshotId}`);
const htmlResponse = await fetch(event.request);
const { html, resources }: RenderedFrameSnapshot = await htmlResponse.json();
if (!html)
return respondNotAvailable();
snapshotResources.set(snapshotId, resources);
snapshotResources.set(snapshotUrl, resources);
const response = new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } });
return response;
}
const resources = snapshotResources.get(snapshotId)!;
const resources = snapshotResources.get(snapshotUrl)!;
const urlWithoutHash = removeHash(request.url);
const resource = resources[urlWithoutHash];
if (!resource)
@ -193,11 +175,18 @@ export class SnapshotServer {
}
private _serveSnapshot(request: http.IncomingMessage, response: http.ServerResponse): boolean {
if (request.url!.endsWith('/snapshot/'))
return this._serveSnapshotRoot(request, response);
if (request.url!.endsWith('/snapshot/service-worker.js'))
return this._serveServiceWorker(request, response);
response.statusCode = 200;
response.setHeader('Cache-Control', 'public, max-age=31536000');
response.setHeader('Content-Type', 'application/json');
const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1));
const snapshot = this._snapshotStorage.snapshotById(parsed.snapshotId);
const [ pageId, query ] = request.url!.substring('/snapshot/'.length).split('?');
const parsed: any = querystring.parse(query);
const snapshot = parsed.name ? this._snapshotStorage.snapshotByName(pageId, parsed.name) : this._snapshotStorage.snapshotByTime(pageId, parsed.time);
const snapshotData: any = snapshot ? snapshot.render() : { html: '' };
response.end(JSON.stringify(snapshotData));
return true;

View file

@ -0,0 +1,109 @@
/**
* 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 { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import util from 'util';
import { ContextResources, FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { SnapshotRenderer } from './snapshotRenderer';
export interface SnapshotStorage {
resources(): ResourceSnapshot[];
resourceContent(sha1: string): Buffer | undefined;
resourceById(resourceId: string): ResourceSnapshot | undefined;
snapshotByName(frameId: string, snapshotName: string): SnapshotRenderer | undefined;
snapshotByTime(frameId: string, timestamp: number): SnapshotRenderer | undefined;
}
export abstract class BaseSnapshotStorage extends EventEmitter implements SnapshotStorage {
protected _resources: ResourceSnapshot[] = [];
protected _resourceMap = new Map<string, ResourceSnapshot>();
protected _frameSnapshots = new Map<string, {
raw: FrameSnapshot[],
renderer: SnapshotRenderer[]
}>();
protected _contextResources: ContextResources = new Map();
addResource(resource: ResourceSnapshot): void {
this._resourceMap.set(resource.resourceId, resource);
this._resources.push(resource);
let resources = this._contextResources.get(resource.url);
if (!resources) {
resources = [];
this._contextResources.set(resource.url, resources);
}
resources.push({ frameId: resource.frameId, resourceId: resource.resourceId });
}
addFrameSnapshot(snapshot: FrameSnapshot): void {
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
if (!frameSnapshots) {
frameSnapshots = {
raw: [],
renderer: [],
};
this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
}
frameSnapshots.raw.push(snapshot);
const renderer = new SnapshotRenderer(new Map(this._contextResources), frameSnapshots.raw, frameSnapshots.raw.length - 1);
frameSnapshots.renderer.push(renderer);
this.emit('snapshot', renderer);
}
abstract resourceContent(sha1: string): Buffer | undefined;
resourceById(resourceId: string): ResourceSnapshot | undefined {
return this._resourceMap.get(resourceId)!;
}
resources(): ResourceSnapshot[] {
return this._resources.slice();
}
snapshotByName(frameId: string, snapshotName: string): SnapshotRenderer | undefined {
return this._frameSnapshots.get(frameId)?.renderer.find(r => r.snapshotName === snapshotName);
}
snapshotByTime(frameId: string, timestamp: number): SnapshotRenderer | undefined {
let result: SnapshotRenderer | undefined = undefined;
for (const snapshot of this._frameSnapshots.get(frameId)?.renderer.values() || []) {
if (timestamp && snapshot.snapshot().timestamp <= timestamp)
result = snapshot;
}
return result;
}
}
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
export class PersistentSnapshotStorage extends BaseSnapshotStorage {
private _resourcesDir: any;
async load(tracePrefix: string, resourcesDir: string) {
this._resourcesDir = resourcesDir;
const networkTrace = await fsReadFileAsync(tracePrefix + '-network.trace', 'utf8');
const resources = networkTrace.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as ResourceSnapshot[];
resources.forEach(r => this.addResource(r));
const snapshotTrace = await fsReadFileAsync(path.join(tracePrefix + '-dom.trace'), 'utf8');
const snapshots = snapshotTrace.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as FrameSnapshot[];
snapshots.forEach(s => this.addFrameSnapshot(s));
}
resourceContent(sha1: string): Buffer | undefined {
return fs.readFileSync(path.join(this._resourcesDir, sha1));
}
}

View file

@ -14,6 +14,21 @@
* limitations under the License.
*/
export type ResourceSnapshot = {
resourceId: string,
pageId: string,
frameId: string,
url: string,
contentType: string,
responseHeaders: { name: string, value: string }[],
requestHeaders: { name: string, value: string }[],
method: string,
status: number,
requestSha1: string,
responseSha1: string,
timestamp: number,
};
export type NodeSnapshot =
// Text node.
string |
@ -34,10 +49,11 @@ export type ResourceOverride = {
};
export type FrameSnapshot = {
snapshotId: string,
snapshotName?: string,
pageId: string,
frameId: string,
frameUrl: string,
timestamp: number,
pageTimestamp: number,
collectionTime: number,
doctype?: string,

View file

@ -21,22 +21,8 @@ import { helper, RegisteredListener } from '../helper';
import { debugLogger } from '../../utils/debugLogger';
import { Frame } from '../frames';
import { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid } from '../../utils/utils';
import { FrameSnapshot } from './snapshot';
export type SnapshotterResource = {
resourceId: string,
pageId: string,
frameId: string,
url: string,
contentType: string,
responseHeaders: { name: string, value: string }[],
requestHeaders: { name: string, value: string }[],
method: string,
status: number,
requestSha1: string,
responseSha1: string,
};
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
export type SnapshotterBlob = {
buffer: Buffer,
@ -45,7 +31,7 @@ export type SnapshotterBlob = {
export interface SnapshotterDelegate {
onBlob(blob: SnapshotterBlob): void;
onResource(resource: SnapshotterResource): void;
onResourceSnapshot(resource: ResourceSnapshot): void;
onFrameSnapshot(snapshot: FrameSnapshot): void;
}
@ -68,13 +54,14 @@ export class Snapshotter {
async initialize() {
await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
const snapshot: FrameSnapshot = {
snapshotId: data.snapshotId,
snapshotName: data.snapshotName,
pageId: source.page.uniqueId,
frameId: source.frame.uniqueId,
frameUrl: data.url,
doctype: data.doctype,
html: data.html,
viewport: data.viewport,
timestamp: monotonicTime(),
pageTimestamp: data.timestamp,
collectionTime: data.collectionTime,
resourceOverrides: [],
@ -105,9 +92,9 @@ export class Snapshotter {
helper.removeEventListeners(this._eventListeners);
}
captureSnapshot(page: Page, snapshotId: string) {
captureSnapshot(page: Page, snapshotName?: string) {
// This needs to be sync, as in not awaiting for anything before we issue the command.
const expression = `window[${JSON.stringify(kSnapshotStreamer)}].captureSnapshot(${JSON.stringify(snapshotId)})`;
const expression = `window[${JSON.stringify(kSnapshotStreamer)}].captureSnapshot(${JSON.stringify(snapshotName)})`;
const snapshotFrame = (frame: Frame) => {
const context = frame._existingMainContext();
context?.rawEvaluate(expression).catch(debugExceptionHandler);
@ -170,7 +157,7 @@ export class Snapshotter {
const requestHeaders = original.headers();
const body = await response.body().catch(e => debugLogger.log('error', e));
const responseSha1 = body ? calculateSha1(body) : 'none';
const resource: SnapshotterResource = {
const resource: ResourceSnapshot = {
pageId: page.uniqueId,
frameId: response.frame().uniqueId,
resourceId: 'resource@' + createGuid(),
@ -182,8 +169,9 @@ export class Snapshotter {
status,
requestSha1,
responseSha1,
timestamp: monotonicTime()
};
this._delegate.onResource(resource);
this._delegate.onResourceSnapshot(resource);
if (requestBody)
this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody });
if (body)

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { NodeSnapshot } from './snapshot';
import { NodeSnapshot } from './snapshotTypes';
export type SnapshotData = {
doctype?: string,
@ -26,7 +26,7 @@ export type SnapshotData = {
}[],
viewport: { width: number, height: number },
url: string,
snapshotId: string,
snapshotName?: string,
timestamp: number,
collectionTime: number,
};
@ -168,29 +168,29 @@ export function frameSnapshotStreamer() {
(iframeElement as any)[kSnapshotFrameId] = frameId;
}
captureSnapshot(snapshotId: string) {
this._streamSnapshot(snapshotId, true);
captureSnapshot(snapshotName?: string) {
this._streamSnapshot(snapshotName);
}
setSnapshotInterval(interval: number) {
this._interval = interval;
if (interval)
this._streamSnapshot(`snapshot@${performance.now()}`, false);
this._streamSnapshot();
}
private _streamSnapshot(snapshotId: string, explicitRequest: boolean) {
private _streamSnapshot(snapshotName?: string) {
if (this._timer) {
clearTimeout(this._timer);
this._timer = undefined;
}
try {
const snapshot = this._captureSnapshot(snapshotId, explicitRequest);
const snapshot = this._captureSnapshot(snapshotName);
if (snapshot)
(window as any)[kSnapshotBinding](snapshot);
} catch (e) {
}
if (this._interval)
this._timer = setTimeout(() => this._streamSnapshot(`snapshot@${performance.now()}`, false), this._interval);
this._timer = setTimeout(() => this._streamSnapshot(), this._interval);
}
private _sanitizeUrl(url: string): string {
@ -240,7 +240,7 @@ export function frameSnapshotStreamer() {
}
}
private _captureSnapshot(snapshotId: string, explicitRequest: boolean): SnapshotData | undefined {
private _captureSnapshot(snapshotName?: string): SnapshotData | undefined {
const timestamp = performance.now();
const snapshotNumber = ++this._lastSnapshotNumber;
let nodeCounter = 0;
@ -365,6 +365,19 @@ export function frameSnapshotStreamer() {
visitChild(child);
}
// Process iframe src attribute before bailing out since it depends on a symbol, not the DOM.
if (nodeName === 'IFRAME' || nodeName === 'FRAME') {
const element = node as Element;
for (let i = 0; i < element.attributes.length; i++) {
const frameId = (element as any)[kSnapshotFrameId];
const name = 'src';
const value = frameId ? `/snapshot/${frameId}` : '';
expectValue(name);
expectValue(value);
attrs[name] = value;
}
}
// We can skip attributes comparison because nothing else has changed,
// and mutation observer didn't tell us about the attributes.
if (equals && data.attributesCached && !shadowDomNesting)
@ -378,22 +391,19 @@ export function frameSnapshotStreamer() {
continue;
if (nodeName === 'LINK' && name === 'integrity')
continue;
if (nodeName === 'IFRAME' && name === 'src')
continue;
let value = element.attributes[i].value;
if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) {
// TODO: handle srcdoc?
const frameId = (element as any)[kSnapshotFrameId];
value = frameId || 'data:text/html,<body>Snapshot is not available</body>';
} else if (name === 'src' && (nodeName === 'IMG')) {
if (name === 'src' && (nodeName === 'IMG'))
value = this._sanitizeUrl(value);
} else if (name === 'srcset' && (nodeName === 'IMG')) {
else if (name === 'srcset' && (nodeName === 'IMG'))
value = this._sanitizeSrcSet(value);
} else if (name === 'srcset' && (nodeName === 'SOURCE')) {
else if (name === 'srcset' && (nodeName === 'SOURCE'))
value = this._sanitizeSrcSet(value);
} else if (name === 'href' && (nodeName === 'LINK')) {
else if (name === 'href' && (nodeName === 'LINK'))
value = this._sanitizeUrl(value);
} else if (name.startsWith('on')) {
else if (name.startsWith('on'))
value = '';
}
expectValue(name);
expectValue(value);
attrs[name] = value;
@ -424,7 +434,7 @@ export function frameSnapshotStreamer() {
height: Math.max(document.body ? document.body.offsetHeight : 0, document.documentElement ? document.documentElement.offsetHeight : 0),
},
url: location.href,
snapshotId,
snapshotName,
timestamp,
collectionTime: 0,
};
@ -444,7 +454,7 @@ export function frameSnapshotStreamer() {
}
result.collectionTime = performance.now() - result.timestamp;
if (!explicitRequest && htmlEquals && allOverridesAreRefs)
if (!snapshotName && htmlEquals && allOverridesAreRefs)
return undefined;
return result;
}

View file

@ -54,7 +54,7 @@ export class Recorder {
private _actionSelector: string | undefined;
private _params: { isUnderTest: boolean; };
private _snapshotIframe: HTMLIFrameElement | undefined;
private _snapshotId: string | undefined;
private _snapshotUrl: string | undefined;
private _snapshotBaseUrl: string;
constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean, snapshotBaseUrl: string }) {
@ -194,7 +194,7 @@ export class Recorder {
return;
}
const { mode, actionPoint, actionSelector, snapshotId } = state;
const { mode, actionPoint, actionSelector, snapshotUrl } = state;
if (mode !== this._mode) {
this._mode = mode;
this._clearHighlight();
@ -223,15 +223,15 @@ export class Recorder {
this._updateHighlight();
this._actionSelector = actionSelector;
}
if (snapshotId !== this._snapshotId) {
this._snapshotId = snapshotId;
if (snapshotUrl !== this._snapshotUrl) {
this._snapshotUrl = snapshotUrl;
const snapshotIframe = this._createSnapshotIframeIfNeeded();
if (snapshotIframe) {
if (!snapshotId) {
if (!snapshotUrl) {
snapshotIframe.style.visibility = 'hidden';
} else {
snapshotIframe.style.visibility = 'visible';
snapshotIframe.contentWindow?.postMessage({ snapshotId }, '*');
snapshotIframe.contentWindow?.postMessage({ snapshotUrl }, '*');
}
}
}

View file

@ -27,7 +27,7 @@ export type UIState = {
mode: Mode;
actionPoint?: Point;
actionSelector?: string;
snapshotId?: string;
snapshotUrl?: string;
};
export type CallLog = {

View file

@ -203,12 +203,12 @@ export class RecorderSupplement {
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
await this._context.exposeBinding('_playwrightRecorderState', false, source => {
let snapshotId: string | undefined;
let snapshotUrl: string | undefined;
let actionSelector: string | undefined;
let actionPoint: Point | undefined;
if (this._hoveredSnapshot) {
snapshotId = this._hoveredSnapshot.phase + '@' + this._hoveredSnapshot.callLogId;
const metadata = this._allMetadatas.get(this._hoveredSnapshot.callLogId);
const metadata = this._allMetadatas.get(this._hoveredSnapshot.callLogId)!;
snapshotUrl = `${metadata.pageId}?name=${this._hoveredSnapshot.phase}@${this._hoveredSnapshot.callLogId}`;
actionPoint = this._hoveredSnapshot.phase === 'in' ? metadata?.point : undefined;
} else {
for (const [metadata, sdkObject] of this._currentCallsMetadata) {
@ -222,7 +222,7 @@ export class RecorderSupplement {
mode: this._mode,
actionPoint,
actionSelector,
snapshotId,
snapshotUrl,
};
return uiState;
});
@ -403,9 +403,9 @@ export class RecorderSupplement {
_captureSnapshot(sdkObject: SdkObject, metadata: CallMetadata, phase: 'before' | 'after' | 'in') {
if (sdkObject.attribution.page) {
const snapshotId = `${phase}@${metadata.id}`;
this._snapshots.add(snapshotId);
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotId);
const snapshotName = `${phase}@${metadata.id}`;
this._snapshots.add(snapshotName);
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName);
}
}

View file

@ -15,7 +15,6 @@
*/
import { StackFrame } from '../../../common/types';
import { FrameSnapshot } from '../../snapshot/snapshot';
export type ContextCreatedTraceEvent = {
timestamp: number,
@ -34,23 +33,6 @@ export type ContextDestroyedTraceEvent = {
contextId: string,
};
export type NetworkResourceTraceEvent = {
timestamp: number,
type: 'resource',
contextId: string,
pageId: string,
frameId: string,
resourceId: string,
url: string,
contentType: string,
responseHeaders: { name: string, value: string }[],
requestHeaders: { name: string, value: string }[],
method: string,
status: number,
requestSha1: string,
responseSha1: string,
};
export type PageCreatedTraceEvent = {
timestamp: number,
type: 'page-created',
@ -86,7 +68,7 @@ export type ActionTraceEvent = {
endTime: number,
logs?: string[],
error?: string,
snapshots?: { name: string, snapshotId: string }[],
snapshots?: { title: string, snapshotName: string }[],
};
export type DialogOpenedEvent = {
@ -122,25 +104,14 @@ export type LoadEvent = {
pageId: string,
};
export type FrameSnapshotTraceEvent = {
timestamp: number,
type: 'snapshot',
contextId: string,
pageId: string,
frameId: string,
snapshot: FrameSnapshot,
};
export type TraceEvent =
ContextCreatedTraceEvent |
ContextDestroyedTraceEvent |
PageCreatedTraceEvent |
PageDestroyedTraceEvent |
PageVideoTraceEvent |
NetworkResourceTraceEvent |
ActionTraceEvent |
DialogOpenedEvent |
DialogClosedEvent |
NavigationEvent |
LoadEvent |
FrameSnapshotTraceEvent;
LoadEvent;

View file

@ -14,24 +14,20 @@
* limitations under the License.
*/
import { BrowserContext, Video } from '../../browserContext';
import type { SnapshotterResource as SnapshotterResource, SnapshotterBlob, SnapshotterDelegate } from '../../snapshot/snapshotter';
import * as trace from '../common/traceEvents';
import fs from 'fs';
import path from 'path';
import * as util from 'util';
import fs from 'fs';
import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
import { Page } from '../../page';
import { Snapshotter } from '../../snapshot/snapshotter';
import { helper, RegisteredListener } from '../../helper';
import { BrowserContext, Video } from '../../browserContext';
import { Dialog } from '../../dialog';
import { Frame, NavigationEvent } from '../../frames';
import { helper, RegisteredListener } from '../../helper';
import { CallMetadata, InstrumentationListener, SdkObject } from '../../instrumentation';
import { FrameSnapshot } from '../../snapshot/snapshot';
import { Page } from '../../page';
import { PersistentSnapshotter } from '../../snapshot/persistentSnapshotter';
import * as trace from '../common/traceEvents';
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
const fsAccessAsync = util.promisify(fs.access.bind(fs));
const envTrace = getFromENV('PW_TRACE_DIR');
export class Tracer implements InstrumentationListener {
@ -42,7 +38,7 @@ export class Tracer implements InstrumentationListener {
if (!traceDir)
return;
const traceStorageDir = path.join(traceDir, 'resources');
const tracePath = path.join(traceDir, createGuid() + '.trace');
const tracePath = path.join(traceDir, createGuid());
const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
await contextTracer.start();
this._contextTracers.set(context, contextTracer);
@ -72,28 +68,25 @@ export class Tracer implements InstrumentationListener {
const snapshotsSymbol = Symbol('snapshots');
// This is an official way to pass snapshots between onBefore/AfterInputAction and onAfterCall.
function snapshotsForMetadata(metadata: CallMetadata): { name: string, snapshotId: string }[] {
function snapshotsForMetadata(metadata: CallMetadata): { title: string, snapshotName: string }[] {
if (!(metadata as any)[snapshotsSymbol])
(metadata as any)[snapshotsSymbol] = [];
return (metadata as any)[snapshotsSymbol];
}
class ContextTracer implements SnapshotterDelegate {
class ContextTracer {
private _contextId: string;
private _traceStoragePromise: Promise<string>;
private _appendEventChain: Promise<string>;
private _writeArtifactChain: Promise<void>;
private _snapshotter: Snapshotter;
private _snapshotter: PersistentSnapshotter;
private _eventListeners: RegisteredListener[];
private _disposed = false;
private _traceFile: string;
constructor(context: BrowserContext, traceStorageDir: string, traceFile: string) {
constructor(context: BrowserContext, traceStorageDir: string, tracePrefix: string) {
const traceFile = tracePrefix + '-actions.trace';
this._contextId = 'context@' + createGuid();
this._traceFile = traceFile;
this._traceStoragePromise = mkdirIfNeeded(path.join(traceStorageDir, 'sha1')).then(() => traceStorageDir);
this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile);
this._writeArtifactChain = Promise.resolve();
const event: trace.ContextCreatedTraceEvent = {
timestamp: monotonicTime(),
type: 'context-created',
@ -105,59 +98,22 @@ class ContextTracer implements SnapshotterDelegate {
debugName: context._options._debugName,
};
this._appendTraceEvent(event);
this._snapshotter = new Snapshotter(context, this);
this._snapshotter = new PersistentSnapshotter(context, tracePrefix, traceStorageDir);
this._eventListeners = [
helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
}
async start() {
await this._snapshotter.initialize();
await this._snapshotter.setAutoSnapshotInterval(100);
}
onBlob(blob: SnapshotterBlob): void {
this._writeArtifact(blob.sha1, blob.buffer);
}
onResource(resource: SnapshotterResource): void {
const event: trace.NetworkResourceTraceEvent = {
timestamp: monotonicTime(),
type: 'resource',
contextId: this._contextId,
pageId: resource.pageId,
frameId: resource.frameId,
resourceId: resource.resourceId,
url: resource.url,
contentType: resource.contentType,
responseHeaders: resource.responseHeaders,
requestHeaders: resource.requestHeaders,
method: resource.method,
status: resource.status,
requestSha1: resource.requestSha1,
responseSha1: resource.responseSha1,
};
this._appendTraceEvent(event);
}
onFrameSnapshot(snapshot: FrameSnapshot): void {
const event: trace.FrameSnapshotTraceEvent = {
timestamp: monotonicTime(),
type: 'snapshot',
contextId: this._contextId,
pageId: snapshot.pageId,
frameId: snapshot.frameId,
snapshot: snapshot,
};
this._appendTraceEvent(event);
await this._snapshotter.start();
}
async onActionCheckpoint(name: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.page)
return;
const snapshotId = createGuid();
snapshotsForMetadata(metadata).push({ name, snapshotId });
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotId);
const snapshotName = `${name}@${metadata.id}`;
snapshotsForMetadata(metadata).push({ title: name, snapshotName });
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName);
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
@ -285,24 +241,7 @@ class ContextTracer implements SnapshotterDelegate {
// Ensure all writes are finished.
await this._appendEventChain;
await this._writeArtifactChain;
}
private _writeArtifact(sha1: string, buffer: Buffer) {
// Save all write promises to wait for them in dispose.
const promise = this._innerWriteArtifact(sha1, buffer);
this._writeArtifactChain = this._writeArtifactChain.then(() => promise);
}
private async _innerWriteArtifact(sha1: string, buffer: Buffer): Promise<void> {
const traceDirectory = await this._traceStoragePromise;
const filePath = path.join(traceDirectory, sha1);
try {
await fsAccessAsync(filePath);
} catch (e) {
// File does not exist - write it.
await fsWriteFileAsync(filePath, buffer);
}
await this._snapshotter.dispose();
}
private _appendTraceEvent(event: any) {

View file

@ -16,19 +16,32 @@
import { createGuid } from '../../../utils/utils';
import * as trace from '../common/traceEvents';
import { SnapshotRenderer } from '../../snapshot/snapshotRenderer';
import { ContextResources } from '../../snapshot/snapshot';
import { ContextResources, ResourceSnapshot } from '../../snapshot/snapshotTypes';
import { SnapshotStorage } from '../../snapshot/snapshotStorage';
export * as trace from '../common/traceEvents';
export class TraceModel {
contextEntries = new Map<string, ContextEntry>();
pageEntries = new Map<string, { contextEntry: ContextEntry, pageEntry: PageEntry }>();
resourceById = new Map<string, trace.NetworkResourceTraceEvent>();
contextResources = new Map<string, ContextResources>();
appendEvents(events: trace.TraceEvent[]) {
appendEvents(events: trace.TraceEvent[], snapshotStorage: SnapshotStorage) {
for (const event of events)
this.appendEvent(event);
const actions: ActionEntry[] = [];
for (const context of this.contextEntries.values()) {
for (const page of context.pages)
actions.push(...page.actions);
}
const resources = snapshotStorage.resources().reverse();
actions.reverse();
for (const action of actions) {
while (resources.length && resources[0].timestamp > action.action.timestamp)
action.resources.push(resources.shift()!);
action.resources.reverse();
}
}
appendEvent(event: trace.TraceEvent) {
@ -54,9 +67,7 @@ export class TraceModel {
created: event,
destroyed: undefined as any,
actions: [],
resources: [],
interestingEvents: [],
snapshotsByFrameId: {},
};
const contextEntry = this.contextEntries.get(event.contextId)!;
this.pageEntries.set(event.pageId, { pageEntry, contextEntry });
@ -75,19 +86,11 @@ export class TraceModel {
const action: ActionEntry = {
actionId,
action: event,
resources: pageEntry.resources,
resources: []
};
pageEntry.resources = [];
pageEntry.actions.push(action);
break;
}
case 'resource': {
const { pageEntry } = this.pageEntries.get(event.pageId!)!;
const action = pageEntry.actions[pageEntry.actions.length - 1];
(action || pageEntry).resources.push(event);
this.appendResource(event);
break;
}
case 'dialog-opened':
case 'dialog-closed':
case 'navigation':
@ -96,40 +99,12 @@ export class TraceModel {
pageEntry.interestingEvents.push(event);
break;
}
case 'snapshot': {
const { pageEntry } = this.pageEntries.get(event.pageId!)!;
let snapshots = pageEntry.snapshotsByFrameId[event.frameId];
if (!snapshots) {
snapshots = [];
pageEntry.snapshotsByFrameId[event.frameId] = snapshots;
}
snapshots.push(event);
for (const override of event.snapshot.resourceOverrides) {
if (override.ref) {
const refOverride = snapshots[snapshots.length - 1 - override.ref]?.snapshot.resourceOverrides.find(o => o.url === override.url);
override.sha1 = refOverride?.sha1;
delete override.ref;
}
}
break;
}
}
const contextEntry = this.contextEntries.get(event.contextId)!;
contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp);
}
appendResource(event: trace.NetworkResourceTraceEvent) {
const contextResources = this.contextResources.get(event.contextId)!;
let responseEvents = contextResources.get(event.url);
if (!responseEvents) {
responseEvents = [];
contextResources.set(event.url, responseEvents);
}
responseEvents.push({ frameId: event.frameId, resourceId: event.resourceId });
this.resourceById.set(event.resourceId, event);
}
actionById(actionId: string): { context: ContextEntry, page: PageEntry, action: ActionEntry } {
const [contextId, pageId, actionIndex] = actionId.split('/');
const context = this.contextEntries.get(contextId)!;
@ -151,27 +126,6 @@ export class TraceModel {
}
return { contextEntry, pageEntry };
}
findSnapshotById(pageId: string, frameId: string, snapshotId: string): SnapshotRenderer | undefined {
const { pageEntry, contextEntry } = this.pageEntries.get(pageId)!;
const frameSnapshots = pageEntry.snapshotsByFrameId[frameId];
for (let index = 0; index < frameSnapshots.length; index++) {
if (frameSnapshots[index].snapshot.snapshotId === snapshotId)
return new SnapshotRenderer(this.contextResources.get(contextEntry.created.contextId)!, frameSnapshots.map(fs => fs.snapshot), index);
}
}
findSnapshotByTime(pageId: string, frameId: string, timestamp: number): SnapshotRenderer | undefined {
const { pageEntry, contextEntry } = this.pageEntries.get(pageId)!;
const frameSnapshots = pageEntry.snapshotsByFrameId[frameId];
let snapshotIndex = -1;
for (let index = 0; index < frameSnapshots.length; index++) {
const snapshot = frameSnapshots[index];
if (timestamp && snapshot.timestamp <= timestamp)
snapshotIndex = index;
}
return snapshotIndex >= 0 ? new SnapshotRenderer(this.contextResources.get(contextEntry.created.contextId)!, frameSnapshots.map(fs => fs.snapshot), snapshotIndex) : undefined;
}
}
export type ContextEntry = {
@ -190,14 +144,12 @@ export type PageEntry = {
destroyed: trace.PageDestroyedTraceEvent;
actions: ActionEntry[];
interestingEvents: InterestingPageEvent[];
resources: trace.NetworkResourceTraceEvent[];
snapshotsByFrameId: { [key: string]: trace.FrameSnapshotTraceEvent[] };
}
export type ActionEntry = {
actionId: string;
action: trace.ActionTraceEvent;
resources: trace.NetworkResourceTraceEvent[];
resources: ResourceSnapshot[]
};
const kInterestingActions = ['click', 'dblclick', 'hover', 'check', 'uncheck', 'tap', 'fill', 'press', 'type', 'selectOption', 'setInputFiles', 'goto', 'setContent', 'goBack', 'goForward', 'reload'];

View file

@ -19,10 +19,10 @@ import path from 'path';
import * as playwright from '../../../..';
import * as util from 'util';
import { TraceModel } from './traceModel';
import { NetworkResourceTraceEvent, TraceEvent } from '../common/traceEvents';
import { TraceEvent } from '../common/traceEvents';
import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer';
import { SnapshotServer, SnapshotStorage } from '../../snapshot/snapshotServer';
import { SnapshotRenderer } from '../../snapshot/snapshotRenderer';
import { SnapshotServer } from '../../snapshot/snapshotServer';
import { PersistentSnapshotStorage } from '../../snapshot/snapshotStorage';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
@ -31,10 +31,10 @@ type TraceViewerDocument = {
model: TraceModel;
};
class TraceViewer implements SnapshotStorage {
class TraceViewer {
private _document: TraceViewerDocument | undefined;
async load(traceDir: string) {
async show(traceDir: string) {
const resourcesDir = path.join(traceDir, 'resources');
const model = new TraceModel();
this._document = {
@ -42,19 +42,6 @@ class TraceViewer implements SnapshotStorage {
resourcesDir,
};
for (const name of fs.readdirSync(traceDir)) {
if (!name.endsWith('.trace'))
continue;
const filePath = path.join(traceDir, name);
const traceContent = await fsReadFileAsync(filePath, 'utf8');
const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[];
model.appendEvents(events);
}
}
async show() {
const browser = await playwright.chromium.launch({ headless: false });
// Served by TraceServer
// - "/tracemodel" - json with trace model.
//
@ -70,8 +57,16 @@ class TraceViewer implements SnapshotStorage {
// - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources
// and translates them into "/resources/<resourceId>".
const actionsTrace = fs.readdirSync(traceDir).find(name => name.endsWith('-actions.trace'))!;
const tracePrefix = path.join(traceDir, actionsTrace.substring(0, actionsTrace.indexOf('-actions.trace')));
const server = new HttpServer();
new SnapshotServer(server, this);
const snapshotStorage = new PersistentSnapshotStorage();
await snapshotStorage.load(tracePrefix, resourcesDir);
new SnapshotServer(server, snapshotStorage);
const traceContent = await fsReadFileAsync(path.join(traceDir, actionsTrace), 'utf8');
const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[];
model.appendEvents(events, snapshotStorage);
const traceModelHandler: ServerRouteHandler = (request, response) => {
response.statusCode = 200;
@ -112,45 +107,15 @@ class TraceViewer implements SnapshotStorage {
server.routePrefix('/sha1/', sha1Handler);
const urlPrefix = await server.start();
const browser = await playwright.chromium.launch({ headless: false });
const uiPage = await browser.newPage({ viewport: null });
uiPage.on('close', () => process.exit(0));
await uiPage.goto(urlPrefix + '/traceviewer/traceViewer/index.html');
}
resourceById(resourceId: string): NetworkResourceTraceEvent | undefined {
const traceModel = this._document!.model;
return traceModel.resourceById.get(resourceId)!;
}
snapshotById(snapshotId: string): SnapshotRenderer | undefined {
const traceModel = this._document!.model;
const parsed = parseSnapshotName(snapshotId);
const snapshot = parsed.snapshotId ? traceModel.findSnapshotById(parsed.pageId, parsed.frameId, parsed.snapshotId) : traceModel.findSnapshotByTime(parsed.pageId, parsed.frameId, parsed.timestamp!);
return snapshot;
}
resourceContent(sha1: string): Buffer | undefined {
return fs.readFileSync(path.join(this._document!.resourcesDir, sha1));
}
}
export async function showTraceViewer(traceDir: string) {
const traceViewer = new TraceViewer();
if (traceDir)
await traceViewer.load(traceDir);
await traceViewer.show();
}
function parseSnapshotName(pathname: string): { pageId: string, frameId: string, timestamp?: number, snapshotId?: string } {
const parts = pathname.split('/');
// - pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
// - pageId/<pageId>/timestamp/<timestamp>/<frameId>
if (parts.length !== 5 || parts[0] !== 'pageId' || (parts[2] !== 'snapshotId' && parts[2] !== 'timestamp'))
throw new Error(`Unexpected path "${pathname}"`);
return {
pageId: parts[1],
frameId: parts[4] === 'main' ? parts[1] : parts[4],
snapshotId: (parts[2] === 'snapshotId' ? parts[3] : undefined),
timestamp: (parts[2] === 'timestamp' ? +parts[3] : undefined),
};
await traceViewer.show(traceDir);
}

View file

@ -17,19 +17,19 @@
import './networkResourceDetails.css';
import * as React from 'react';
import { Expandable } from './helpers';
import { NetworkResourceTraceEvent } from '../../../server/trace/common/traceEvents';
import type { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes';
const utf8Encoder = new TextDecoder('utf-8');
export const NetworkResourceDetails: React.FunctionComponent<{
resource: NetworkResourceTraceEvent,
resource: ResourceSnapshot,
index: number,
selected: boolean,
setSelected: React.Dispatch<React.SetStateAction<number>>,
}> = ({ resource, index, selected, setSelected }) => {
const [expanded, setExpanded] = React.useState(false);
const [requestBody, setRequestBody] = React.useState<string | null>(null);
const [responseBody, setResponseBody] = React.useState<ArrayBuffer | null>(null);
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string } | null>(null);
React.useEffect(() => {
setExpanded(false);
@ -45,14 +45,22 @@ export const NetworkResourceDetails: React.FunctionComponent<{
}
if (resource.responseSha1 !== 'none') {
const useBase64 = resource.contentType.includes('image');
const response = await fetch(`/sha1/${resource.responseSha1}`);
const responseResource = await response.arrayBuffer();
setResponseBody(responseResource);
if (useBase64) {
const blob = await response.blob();
const reader = new FileReader();
const eventPromise = new Promise<any>(f => reader.onload = f);
reader.readAsDataURL(blob);
setResponseBody({ dataUrl: (await eventPromise).target.result });
} else {
setResponseBody({ text: await response.text() });
}
}
};
readResources();
}, [expanded, resource.responseSha1, resource.requestSha1]);
}, [expanded, resource.responseSha1, resource.requestSha1, resource.contentType]);
function formatBody(body: string | null, contentType: string): string {
if (body === null)
@ -111,8 +119,8 @@ export const NetworkResourceDetails: React.FunctionComponent<{
{resource.requestSha1 !== 'none' ? <div className='network-request-body'>{formatBody(requestBody, requestContentType)}</div> : ''}
<h4>Response Body</h4>
{resource.responseSha1 === 'none' ? <div className='network-request-response-body'>Response body is not available for this request.</div> : ''}
{responseBody !== null && resource.contentType.includes('image') ? <img src={`data:${resource.contentType};base64,${btoa(String.fromCharCode(...new Uint8Array(responseBody)))}`} /> : ''}
{responseBody !== null && !resource.contentType.includes('image') ? <div className='network-request-response-body'>{formatBody(utf8Encoder.decode(responseBody), resource.contentType)}</div> : ''}
{responseBody !== null && responseBody.dataUrl ? <img src={responseBody.dataUrl} /> : ''}
{responseBody !== null && responseBody.text ? <div className='network-request-response-body'>{formatBody(responseBody.text, resource.contentType)}</div> : ''}
</div>
}/>
</div>;

View file

@ -30,5 +30,3 @@ export const NetworkTab: React.FunctionComponent<{
})
}</div>;
};

View file

@ -30,14 +30,7 @@ export const SnapshotTab: React.FunctionComponent<{
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = [];
snapshots = (actionEntry ? (actionEntry.action.snapshots || []) : []).slice();
if (actionEntry) {
if (!snapshots.length || snapshots[0].name !== 'before')
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 });
}
const snapshots = actionEntry ? (actionEntry.action.snapshots || []) : [];
const { pageId, time } = selection || { pageId: undefined, time: 0 };
const iframeRef = React.createRef<HTMLIFrameElement>();
@ -45,18 +38,15 @@ export const SnapshotTab: React.FunctionComponent<{
if (!iframeRef.current)
return;
// TODO: this logic is copied from SnapshotServer. Find a way to share.
let snapshotUrl = 'data:text/html,Snapshot is not available';
let snapshotUri = undefined;
if (pageId) {
snapshotUrl = `/snapshot/pageId/${pageId}/timestamp/${time}/main`;
snapshotUri = `${pageId}?time=${time}`;
} else if (actionEntry) {
const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotTime)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`;
else if (snapshot && snapshot.snapshotId)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`;
if (snapshot && snapshot.snapshotName)
snapshotUri = `${actionEntry.action.pageId}?name=${snapshot.snapshotName}`;
}
const snapshotUrl = snapshotUri ? `${window.location.origin}/snapshot/${snapshotUri}` : 'data:text/html,Snapshot is not available';
try {
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl);
} catch (e) {
@ -71,10 +61,10 @@ export const SnapshotTab: React.FunctionComponent<{
</div>
}{!selection && snapshots.map((snapshot, index) => {
return <div
key={snapshot.name}
key={snapshot.title}
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
onClick={() => setSnapshotIndex(index)}>
{snapshot.name}
{snapshot.title}
</div>
})
}</div>

View file

@ -1,5 +0,0 @@
@import url(./two.css);
body {
background-color: pink;
}

View file

@ -1,58 +0,0 @@
<link rel='stylesheet' href='./one.css'>
<style>
.root {
width: 800px;
height: 800px;
background: cyan;
}
</style>
<div>hello, world!</div>
<div class=root></div>
<script>
let shadow;
window.addEventListener('DOMContentLoaded', () => {
const root = document.querySelector('.root');
shadow = root.attachShadow({ mode: 'open' });
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './one.css';
shadow.appendChild(link);
const imaged = document.createElement('div');
imaged.className = 'imaged';
shadow.appendChild(imaged);
const textarea = document.createElement('textarea');
textarea.textContent = 'Before edit';
textarea.style.display = 'block';
shadow.appendChild(textarea);
const iframe = document.createElement('iframe');
iframe.width = '600px';
iframe.height = '600px';
iframe.src = '../frames/nested-frames.html';
shadow.appendChild(iframe);
});
window.addEventListener('load', () => {
setTimeout(() => {
shadow.querySelector('textarea').value = 'After edit';
for (const rule of document.styleSheets[1].cssRules) {
if (rule.cssText.includes('background: cyan'))
rule.style.background = 'magenta';
}
for (const rule of shadow.styleSheets[0].cssRules) {
if (rule.styleSheet) {
for (const rule2 of rule.styleSheet.cssRules) {
if (rule2.cssText.includes('width: 200px'))
rule2.style.width = '400px';
}
}
}
}, 500);
});
</script>

View file

@ -1,5 +0,0 @@
.imaged {
width: 200px;
height: 200px;
background: url(../pptr.png);
}

View file

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Tracer XHR Network Resource example</title>
</head>
<body>
<div id="response-status"></div>
<div id="response-body"></div>
<script>
async function performXHR() {
const response = await window.fetch('./file.json', {method: 'POST', body: JSON.stringify({prop: 'value'})});
document.querySelector('#response-status').innerText = response.status;
const responseText = await response.text();
document.querySelector('#response-body').innerText = responseText;
}
</script>
<a onclick="javascipt:performXHR();">Download</a>
</body>
</html>

View file

@ -16,9 +16,12 @@
import { folio as baseFolio } from './fixtures';
import { InMemorySnapshotter } from '../lib/server/snapshot/inMemorySnapshotter';
import { HttpServer } from '../lib/utils/httpServer';
import { SnapshotServer } from '../lib/server/snapshot/snapshotServer';
type TestFixtures = {
snapshotter: any;
snapshotPort: number;
};
export const fixtures = baseFolio.extend<TestFixtures>();
@ -29,6 +32,15 @@ fixtures.snapshotter.init(async ({ context, toImpl }, runTest) => {
await snapshotter.dispose();
});
fixtures.snapshotPort.init(async ({ snapshotter, testWorkerIndex }, runTest) => {
const httpServer = new HttpServer();
new SnapshotServer(httpServer, snapshotter);
const port = 9700 + testWorkerIndex;
httpServer.start(port);
await runTest(port);
httpServer.stop();
});
const { it, describe, expect } = fixtures.build();
describe('snapshots', (suite, { mode }) => {
@ -122,12 +134,53 @@ describe('snapshots', (suite, { mode }) => {
const { sha1 } = resources[cssHref];
expect(snapshotter.resourceContent(sha1).toString()).toBe('button { color: blue; }');
});
it('should capture iframe', (test, { browserName }) => {
test.skip(browserName === 'firefox');
}, async ({ contextFactory, snapshotter, page, server, snapshotPort, toImpl }) => {
await page.route('**/empty.html', route => {
route.fulfill({
body: '<iframe src="iframe.html"></iframe>',
contentType: 'text/html'
}).catch(() => {});
});
await page.route('**/iframe.html', route => {
route.fulfill({
body: '<html><button>Hello iframe</button></html>',
contentType: 'text/html'
}).catch(() => {});
});
await page.goto(server.EMPTY_PAGE);
// Marking iframe hierarchy is racy, do not expect snapshot, wait for it.
let counter = 0;
let snapshot: any;
for (; ; ++counter) {
snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot' + counter);
const text = distillSnapshot(snapshot).replace(/frame@[^"]+["]/, '<id>"');
if (text === '<IFRAME src=\"/snapshot/<id>\"></IFRAME>')
break;
await page.waitForTimeout(250);
}
// Render snapshot, check expectations.
const previewContext = await contextFactory();
const previewPage = await previewContext.newPage();
await previewPage.goto(`http://localhost:${snapshotPort}/snapshot/`);
await previewPage.evaluate(snapshotId => {
(window as any).showSnapshot(snapshotId);
}, `${snapshot.snapshot().pageId}?name=snapshot${counter}`);
while (previewPage.frames().length < 4)
await new Promise(f => previewPage.once('frameattached', f));
const button = await previewPage.frames()[3].waitForSelector('button');
expect(await button.textContent()).toBe('Hello iframe');
});
});
function distillSnapshot(snapshot) {
const { html } = snapshot.render();
return html
.replace(/<script>function snapshotScript[.\s\S]*/, '')
.replace(/<script>[.\s\S]+<\/script>/, '')
.replace(/<BASE href="about:blank">/, '')
.replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '')
.replace(/<HTML>/, '')

View file

@ -1,131 +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 { it, expect } from './fixtures';
import type * as trace from '../src/server/trace/common/traceEvents';
import path from 'path';
import fs from 'fs';
it('should record trace', (test, { browserName, platform }) => {
test.fixme();
}, async ({browser, testInfo, server}) => {
const traceDir = testInfo.outputPath('trace');
const context = await browser.newContext({ _traceDir: traceDir } as any);
const page = await context.newPage();
const url = server.PREFIX + '/snapshot/snapshot-with-css.html';
await page.goto(url);
await page.click('textarea');
await context.close();
const tracePath = path.join(traceDir, fs.readdirSync(traceDir).find(n => n.endsWith('.trace')));
const traceFileContent = await fs.promises.readFile(tracePath, 'utf8');
const traceEvents = traceFileContent.split('\n').filter(line => !!line).map(line => JSON.parse(line)) as trace.TraceEvent[];
const contextEvent = traceEvents.find(event => event.type === 'context-created') as trace.ContextCreatedTraceEvent;
expect(contextEvent).toBeTruthy();
expect(contextEvent.debugName).toBeUndefined();
const contextId = contextEvent.contextId;
const pageEvent = traceEvents.find(event => event.type === 'page-created') as trace.PageCreatedTraceEvent;
expect(pageEvent).toBeTruthy();
expect(pageEvent.contextId).toBe(contextId);
const pageId = pageEvent.pageId;
const gotoEvent = traceEvents.find(event => event.type === 'action' && event.method === 'goto') as trace.ActionTraceEvent;
expect(gotoEvent).toBeTruthy();
expect(gotoEvent.contextId).toBe(contextId);
expect(gotoEvent.pageId).toBe(pageId);
expect(gotoEvent.params.url).toBe(url);
const resourceEvent = traceEvents.find(event => event.type === 'resource' && event.url.endsWith('/frames/style.css')) as trace.NetworkResourceTraceEvent;
expect(resourceEvent).toBeTruthy();
expect(resourceEvent.contextId).toBe(contextId);
expect(resourceEvent.pageId).toBe(pageId);
expect(resourceEvent.method).toBe('GET');
expect(resourceEvent.status).toBe(200);
expect(resourceEvent.requestHeaders).toBeTruthy();
expect(resourceEvent.requestHeaders.length).toBeGreaterThan(0);
expect(resourceEvent.requestSha1).toBe('none');
const clickEvent = traceEvents.find(event => event.type === 'action' && event.method === 'click') as trace.ActionTraceEvent;
expect(clickEvent).toBeTruthy();
expect(clickEvent.snapshots.length).toBe(2);
const snapshotId = clickEvent.snapshots[0].snapshotId;
const snapshotEvent = traceEvents.find(event => event.type === 'snapshot' && event.snapshot.snapshotId === snapshotId) as trace.FrameSnapshotTraceEvent;
expect(snapshotEvent).toBeTruthy();
});
it('should record trace with POST', (test, { browserName, platform }) => {
test.fixme();
}, async ({browser, testInfo, server}) => {
const traceDir = testInfo.outputPath('trace');
const context = await browser.newContext({ _traceDir: traceDir } as any);
const page = await context.newPage();
const url = server.PREFIX + '/trace-resources.html';
await page.goto(url);
await page.click('text=Download');
await page.waitForSelector(`#response-status:text("404")`);
await context.close();
const tracePath = path.join(traceDir, fs.readdirSync(traceDir).find(n => n.endsWith('.trace')));
const traceFileContent = await fs.promises.readFile(tracePath, 'utf8');
const traceEvents = traceFileContent.split('\n').filter(line => !!line).map(line => JSON.parse(line)) as trace.TraceEvent[];
const contextEvent = traceEvents.find(event => event.type === 'context-created') as trace.ContextCreatedTraceEvent;
expect(contextEvent).toBeTruthy();
expect(contextEvent.debugName).toBeUndefined();
const contextId = contextEvent.contextId;
const pageEvent = traceEvents.find(event => event.type === 'page-created') as trace.PageCreatedTraceEvent;
expect(pageEvent).toBeTruthy();
expect(pageEvent.contextId).toBe(contextId);
const pageId = pageEvent.pageId;
const gotoEvent = traceEvents.find(event => event.type === 'action' && event.method === 'goto') as trace.ActionTraceEvent;
expect(gotoEvent).toBeTruthy();
expect(gotoEvent.contextId).toBe(contextId);
expect(gotoEvent.pageId).toBe(pageId);
expect(gotoEvent.params.url).toBe(url);
const resourceEvent = traceEvents.find(event => event.type === 'resource' && event.url.endsWith('/file.json')) as trace.NetworkResourceTraceEvent;
expect(resourceEvent).toBeTruthy();
expect(resourceEvent.contextId).toBe(contextId);
expect(resourceEvent.pageId).toBe(pageId);
expect(resourceEvent.method).toBe('POST');
expect(resourceEvent.status).toBe(404);
expect(resourceEvent.requestHeaders).toBeTruthy();
expect(resourceEvent.requestHeaders.length).toBeGreaterThan(0);
expect(resourceEvent.requestSha1).toBeTruthy();
expect(resourceEvent.responseSha1).toBeTruthy();
expect(fs.existsSync(path.join(traceDir, 'resources', resourceEvent.requestSha1))).toBe(true);
expect(fs.existsSync(path.join(traceDir, 'resources', resourceEvent.responseSha1))).toBe(true);
});
it('should record trace with a debugName', (test, { browserName, platform }) => {
test.fixme();
}, async ({browser, testInfo, server}) => {
const traceDir = testInfo.outputPath('trace');
const debugName = 'Custom testcase name';
const context = await browser.newContext({ _traceDir: traceDir, _debugName: debugName } as any);
await context.close();
const tracePath = path.join(traceDir, fs.readdirSync(traceDir).find(n => n.endsWith('.trace')));
const traceFileContent = await fs.promises.readFile(tracePath, 'utf8');
const traceEvents = traceFileContent.split('\n').filter(line => !!line).map(line => JSON.parse(line)) as trace.TraceEvent[];
const contextEvent = traceEvents.find(event => event.type === 'context-created') as trace.ContextCreatedTraceEvent;
expect(contextEvent).toBeTruthy();
expect(contextEvent.debugName).toBe(debugName);
});

View file

@ -146,7 +146,7 @@ DEPS['src/cli/driver.ts'] = DEPS['src/inprocess.ts'] = DEPS['src/browserServerIm
// Tracing is a client/server plugin, nothing should depend on it.
DEPS['src/web/recorder/'] = ['src/common/', 'src/web/', 'src/web/components/', 'src/server/supplements/recorder/recorderTypes.ts'];
DEPS['src/web/traceViewer/'] = ['src/common/', 'src/web/'];
DEPS['src/web/traceViewer/ui/'] = ['src/common/', 'src/web/traceViewer/', 'src/web/', 'src/server/trace/viewer/', 'src/server/trace/', 'src/server/trace/common/'];
DEPS['src/web/traceViewer/ui/'] = ['src/common/', 'src/web/traceViewer/', 'src/web/', 'src/server/trace/viewer/', 'src/server/trace/', 'src/server/trace/common/', 'src/server/snapshot/snapshotTypes.ts'];
// The service is a cross-cutting feature, and so it depends on a bunch of things.
DEPS['src/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/server/', 'src/server/supplements/', 'src/server/electron/', 'src/server/trace/'];
DEPS['src/service.ts'] = ['src/remote/'];