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

View file

@ -14,24 +14,20 @@
* limitations under the License. * limitations under the License.
*/ */
import { EventEmitter } from 'events';
import { HttpServer } from '../../utils/httpServer'; import { HttpServer } from '../../utils/httpServer';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { helper } from '../helper'; import { helper } from '../helper';
import { Page } from '../page'; import { Page } from '../page';
import { ContextResources, FrameSnapshot } from './snapshot'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { SnapshotRenderer } from './snapshotRenderer'; import { SnapshotRenderer } from './snapshotRenderer';
import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer'; import { SnapshotServer } from './snapshotServer';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter'; import { BaseSnapshotStorage } from './snapshotStorage';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
const kSnapshotInterval = 25; 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 _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 _server: HttpServer;
private _snapshotter: Snapshotter; private _snapshotter: Snapshotter;
@ -56,14 +52,14 @@ export class InMemorySnapshotter extends EventEmitter implements SnapshotStorage
await this._server.stop(); await this._server.stop();
} }
async captureSnapshot(page: Page, snapshotId: string): Promise<SnapshotRenderer> { async captureSnapshot(page: Page, snapshotName: string): Promise<SnapshotRenderer> {
if (this._snapshots.has(snapshotId)) if (this._frameSnapshots.has(snapshotName))
throw new Error('Duplicate snapshotId: ' + snapshotId); throw new Error('Duplicate snapshot name: ' + snapshotName);
this._snapshotter.captureSnapshot(page, snapshotId); this._snapshotter.captureSnapshot(page, snapshotName);
return new Promise<SnapshotRenderer>(fulfill => { return new Promise<SnapshotRenderer>(fulfill => {
const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => { const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => {
if (renderer.snapshotId === snapshotId) { if (renderer.snapshotName === snapshotName) {
helper.removeEventListeners([listener]); helper.removeEventListeners([listener]);
fulfill(renderer); fulfill(renderer);
} }
@ -79,41 +75,15 @@ export class InMemorySnapshotter extends EventEmitter implements SnapshotStorage
this._blobs.set(blob.sha1, blob.buffer); this._blobs.set(blob.sha1, blob.buffer);
} }
onResource(resource: SnapshotterResource): void { onResourceSnapshot(resource: ResourceSnapshot): void {
this._resources.set(resource.resourceId, resource); this.addResource(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 });
} }
onFrameSnapshot(snapshot: FrameSnapshot): void { onFrameSnapshot(snapshot: FrameSnapshot): void {
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId); this.addFrameSnapshot(snapshot);
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);
} }
resourceContent(sha1: string): Buffer | undefined { resourceContent(sha1: string): Buffer | undefined {
return this._blobs.get(sha1); 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. * limitations under the License.
*/ */
import { ContextResources, FrameSnapshot, NodeSnapshot, RenderedFrameSnapshot } from './snapshot'; import { ContextResources, FrameSnapshot, NodeSnapshot, RenderedFrameSnapshot } from './snapshotTypes';
export class SnapshotRenderer { export class SnapshotRenderer {
private _snapshots: FrameSnapshot[]; private _snapshots: FrameSnapshot[];
private _index: number; private _index: number;
private _contextResources: ContextResources; private _contextResources: ContextResources;
readonly snapshotId: string; readonly snapshotName: string | undefined;
constructor(contextResources: ContextResources, snapshots: FrameSnapshot[], index: number) { constructor(contextResources: ContextResources, snapshots: FrameSnapshot[], index: number) {
this._contextResources = contextResources; this._contextResources = contextResources;
this._snapshots = snapshots; this._snapshots = snapshots;
this._index = index; this._index = index;
this.snapshotId = snapshots[index].snapshotId; this.snapshotName = snapshots[index].snapshotName;
}
snapshot(): FrameSnapshot {
return this._snapshots[this._index];
} }
render(): RenderedFrameSnapshot { render(): RenderedFrameSnapshot {
@ -69,7 +73,7 @@ export class SnapshotRenderer {
let html = visit(snapshot.html, this._index); let html = visit(snapshot.html, this._index);
if (snapshot.doctype) if (snapshot.doctype)
html = `<!DOCTYPE ${snapshot.doctype}>` + html; html = `<!DOCTYPE ${snapshot.doctype}>` + html;
html += `<script>${snapshotScript}</script>`; html += `<script>${snapshotScript()}</script>`;
const resources: { [key: string]: { resourceId: string, sha1?: string } } = {}; const resources: { [key: string]: { resourceId: string, sha1?: string } } = {};
for (const [url, contextResources] of this._contextResources) { for (const [url, contextResources] of this._contextResources) {
@ -113,7 +117,7 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
return (snapshot as any)._nodes; return (snapshot as any)._nodes;
} }
export function snapshotScript() { function snapshotScript() {
function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) { function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) {
const scrollTops: Element[] = []; const scrollTops: Element[] = [];
const scrollLefts: Element[] = []; const scrollLefts: Element[] = [];
@ -126,17 +130,13 @@ export function snapshotScript() {
scrollLefts.push(e); scrollLefts.push(e);
for (const iframe of root.querySelectorAll('iframe')) { for (const iframe of root.querySelectorAll('iframe')) {
const src = iframe.getAttribute('src') || ''; const src = iframe.getAttribute('src');
if (src.startsWith('data:text/html')) if (!src) {
continue; iframe.setAttribute('src', 'data:text/html,<body>Snapshot is not available</body>');
// Rewrite iframes to use snapshot url (relative to window.location) } else {
// instead of begin relative to the <base> tag. // Append query parameters to inherit ?name= or ?time= values from parent.
const index = location.pathname.lastIndexOf('/'); iframe.setAttribute('src', window.location.origin + src + window.location.search);
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}]`)) { for (const element of root.querySelectorAll(`template[${shadowAttribute}]`)) {

View file

@ -16,21 +16,9 @@
import * as http from 'http'; import * as http from 'http';
import querystring from 'querystring'; import querystring from 'querystring';
import { SnapshotRenderer } from './snapshotRenderer';
import { HttpServer } from '../../utils/httpServer'; import { HttpServer } from '../../utils/httpServer';
import type { RenderedFrameSnapshot } from './snapshot'; import type { RenderedFrameSnapshot } from './snapshotTypes';
import { SnapshotStorage } from './snapshotStorage';
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;
}
export class SnapshotServer { export class SnapshotServer {
private _snapshotStorage: SnapshotStorage; private _snapshotStorage: SnapshotStorage;
@ -38,9 +26,7 @@ export class SnapshotServer {
constructor(server: HttpServer, snapshotStorage: SnapshotStorage) { constructor(server: HttpServer, snapshotStorage: SnapshotStorage) {
this._snapshotStorage = snapshotStorage; this._snapshotStorage = snapshotStorage;
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this)); server.routePrefix('/snapshot/', this._serveSnapshot.bind(this));
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
server.routePrefix('/resources/', this._serveResource.bind(this)); server.routePrefix('/resources/', this._serveResource.bind(this));
} }
@ -91,7 +77,7 @@ export class SnapshotServer {
next.src = url; next.src = url;
}; };
window.addEventListener('message', event => { window.addEventListener('message', event => {
window.showSnapshot(window.location.href + event.data.snapshotId); window.showSnapshot(window.location.href + event.data.snapshotUrl);
}, false); }, false);
} }
</script> </script>
@ -135,24 +121,20 @@ export class SnapshotServer {
if (pathname === '/snapshot/service-worker.js' || pathname === '/snapshot/') if (pathname === '/snapshot/service-worker.js' || pathname === '/snapshot/')
return fetch(event.request); 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') { if (request.mode === 'navigate') {
snapshotId = pathname.substring('/snapshot/'.length); const htmlResponse = await fetch(event.request);
} 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 { html, resources }: RenderedFrameSnapshot = await htmlResponse.json(); const { html, resources }: RenderedFrameSnapshot = await htmlResponse.json();
if (!html) if (!html)
return respondNotAvailable(); return respondNotAvailable();
snapshotResources.set(snapshotId, resources); snapshotResources.set(snapshotUrl, resources);
const response = new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } }); const response = new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } });
return response; return response;
} }
const resources = snapshotResources.get(snapshotId)!; const resources = snapshotResources.get(snapshotUrl)!;
const urlWithoutHash = removeHash(request.url); const urlWithoutHash = removeHash(request.url);
const resource = resources[urlWithoutHash]; const resource = resources[urlWithoutHash];
if (!resource) if (!resource)
@ -193,11 +175,18 @@ export class SnapshotServer {
} }
private _serveSnapshot(request: http.IncomingMessage, response: http.ServerResponse): boolean { 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.statusCode = 200;
response.setHeader('Cache-Control', 'public, max-age=31536000'); response.setHeader('Cache-Control', 'public, max-age=31536000');
response.setHeader('Content-Type', 'application/json'); response.setHeader('Content-Type', 'application/json');
const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1)); const [ pageId, query ] = request.url!.substring('/snapshot/'.length).split('?');
const snapshot = this._snapshotStorage.snapshotById(parsed.snapshotId); 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: '' }; const snapshotData: any = snapshot ? snapshot.render() : { html: '' };
response.end(JSON.stringify(snapshotData)); response.end(JSON.stringify(snapshotData));
return true; 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. * 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 = export type NodeSnapshot =
// Text node. // Text node.
string | string |
@ -34,10 +49,11 @@ export type ResourceOverride = {
}; };
export type FrameSnapshot = { export type FrameSnapshot = {
snapshotId: string, snapshotName?: string,
pageId: string, pageId: string,
frameId: string, frameId: string,
frameUrl: string, frameUrl: string,
timestamp: number,
pageTimestamp: number, pageTimestamp: number,
collectionTime: number, collectionTime: number,
doctype?: string, doctype?: string,

View file

@ -21,22 +21,8 @@ import { helper, RegisteredListener } from '../helper';
import { debugLogger } from '../../utils/debugLogger'; import { debugLogger } from '../../utils/debugLogger';
import { Frame } from '../frames'; import { Frame } from '../frames';
import { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected'; import { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid } from '../../utils/utils'; import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot } from './snapshot'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
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,
};
export type SnapshotterBlob = { export type SnapshotterBlob = {
buffer: Buffer, buffer: Buffer,
@ -45,7 +31,7 @@ export type SnapshotterBlob = {
export interface SnapshotterDelegate { export interface SnapshotterDelegate {
onBlob(blob: SnapshotterBlob): void; onBlob(blob: SnapshotterBlob): void;
onResource(resource: SnapshotterResource): void; onResourceSnapshot(resource: ResourceSnapshot): void;
onFrameSnapshot(snapshot: FrameSnapshot): void; onFrameSnapshot(snapshot: FrameSnapshot): void;
} }
@ -68,13 +54,14 @@ export class Snapshotter {
async initialize() { async initialize() {
await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => { await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
const snapshot: FrameSnapshot = { const snapshot: FrameSnapshot = {
snapshotId: data.snapshotId, snapshotName: data.snapshotName,
pageId: source.page.uniqueId, pageId: source.page.uniqueId,
frameId: source.frame.uniqueId, frameId: source.frame.uniqueId,
frameUrl: data.url, frameUrl: data.url,
doctype: data.doctype, doctype: data.doctype,
html: data.html, html: data.html,
viewport: data.viewport, viewport: data.viewport,
timestamp: monotonicTime(),
pageTimestamp: data.timestamp, pageTimestamp: data.timestamp,
collectionTime: data.collectionTime, collectionTime: data.collectionTime,
resourceOverrides: [], resourceOverrides: [],
@ -105,9 +92,9 @@ export class Snapshotter {
helper.removeEventListeners(this._eventListeners); 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. // 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 snapshotFrame = (frame: Frame) => {
const context = frame._existingMainContext(); const context = frame._existingMainContext();
context?.rawEvaluate(expression).catch(debugExceptionHandler); context?.rawEvaluate(expression).catch(debugExceptionHandler);
@ -170,7 +157,7 @@ export class Snapshotter {
const requestHeaders = original.headers(); const requestHeaders = original.headers();
const body = await response.body().catch(e => debugLogger.log('error', e)); const body = await response.body().catch(e => debugLogger.log('error', e));
const responseSha1 = body ? calculateSha1(body) : 'none'; const responseSha1 = body ? calculateSha1(body) : 'none';
const resource: SnapshotterResource = { const resource: ResourceSnapshot = {
pageId: page.uniqueId, pageId: page.uniqueId,
frameId: response.frame().uniqueId, frameId: response.frame().uniqueId,
resourceId: 'resource@' + createGuid(), resourceId: 'resource@' + createGuid(),
@ -182,8 +169,9 @@ export class Snapshotter {
status, status,
requestSha1, requestSha1,
responseSha1, responseSha1,
timestamp: monotonicTime()
}; };
this._delegate.onResource(resource); this._delegate.onResourceSnapshot(resource);
if (requestBody) if (requestBody)
this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody }); this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody });
if (body) if (body)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,24 +14,20 @@
* limitations under the License. * limitations under the License.
*/ */
import { BrowserContext, Video } from '../../browserContext'; import fs from 'fs';
import type { SnapshotterResource as SnapshotterResource, SnapshotterBlob, SnapshotterDelegate } from '../../snapshot/snapshotter';
import * as trace from '../common/traceEvents';
import path from 'path'; import path from 'path';
import * as util from 'util'; import * as util from 'util';
import fs from 'fs';
import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils'; import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
import { Page } from '../../page'; import { BrowserContext, Video } from '../../browserContext';
import { Snapshotter } from '../../snapshot/snapshotter';
import { helper, RegisteredListener } from '../../helper';
import { Dialog } from '../../dialog'; import { Dialog } from '../../dialog';
import { Frame, NavigationEvent } from '../../frames'; import { Frame, NavigationEvent } from '../../frames';
import { helper, RegisteredListener } from '../../helper';
import { CallMetadata, InstrumentationListener, SdkObject } from '../../instrumentation'; 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 fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
const fsAccessAsync = util.promisify(fs.access.bind(fs));
const envTrace = getFromENV('PW_TRACE_DIR'); const envTrace = getFromENV('PW_TRACE_DIR');
export class Tracer implements InstrumentationListener { export class Tracer implements InstrumentationListener {
@ -42,7 +38,7 @@ export class Tracer implements InstrumentationListener {
if (!traceDir) if (!traceDir)
return; return;
const traceStorageDir = path.join(traceDir, 'resources'); 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); const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
await contextTracer.start(); await contextTracer.start();
this._contextTracers.set(context, contextTracer); this._contextTracers.set(context, contextTracer);
@ -72,28 +68,25 @@ export class Tracer implements InstrumentationListener {
const snapshotsSymbol = Symbol('snapshots'); const snapshotsSymbol = Symbol('snapshots');
// This is an official way to pass snapshots between onBefore/AfterInputAction and onAfterCall. // 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]) if (!(metadata as any)[snapshotsSymbol])
(metadata as any)[snapshotsSymbol] = []; (metadata as any)[snapshotsSymbol] = [];
return (metadata as any)[snapshotsSymbol]; return (metadata as any)[snapshotsSymbol];
} }
class ContextTracer implements SnapshotterDelegate { class ContextTracer {
private _contextId: string; private _contextId: string;
private _traceStoragePromise: Promise<string>;
private _appendEventChain: Promise<string>; private _appendEventChain: Promise<string>;
private _writeArtifactChain: Promise<void>; private _snapshotter: PersistentSnapshotter;
private _snapshotter: Snapshotter;
private _eventListeners: RegisteredListener[]; private _eventListeners: RegisteredListener[];
private _disposed = false; private _disposed = false;
private _traceFile: string; 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._contextId = 'context@' + createGuid();
this._traceFile = traceFile; this._traceFile = traceFile;
this._traceStoragePromise = mkdirIfNeeded(path.join(traceStorageDir, 'sha1')).then(() => traceStorageDir);
this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile); this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile);
this._writeArtifactChain = Promise.resolve();
const event: trace.ContextCreatedTraceEvent = { const event: trace.ContextCreatedTraceEvent = {
timestamp: monotonicTime(), timestamp: monotonicTime(),
type: 'context-created', type: 'context-created',
@ -105,59 +98,22 @@ class ContextTracer implements SnapshotterDelegate {
debugName: context._options._debugName, debugName: context._options._debugName,
}; };
this._appendTraceEvent(event); this._appendTraceEvent(event);
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new PersistentSnapshotter(context, tracePrefix, traceStorageDir);
this._eventListeners = [ this._eventListeners = [
helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)), helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)),
]; ];
} }
async start() { async start() {
await this._snapshotter.initialize(); await this._snapshotter.start();
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);
} }
async onActionCheckpoint(name: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onActionCheckpoint(name: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.page) if (!sdkObject.attribution.page)
return; return;
const snapshotId = createGuid(); const snapshotName = `${name}@${metadata.id}`;
snapshotsForMetadata(metadata).push({ name, snapshotId }); snapshotsForMetadata(metadata).push({ title: name, snapshotName });
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotId); this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName);
} }
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
@ -285,24 +241,7 @@ class ContextTracer implements SnapshotterDelegate {
// Ensure all writes are finished. // Ensure all writes are finished.
await this._appendEventChain; await this._appendEventChain;
await this._writeArtifactChain; await this._snapshotter.dispose();
}
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);
}
} }
private _appendTraceEvent(event: any) { private _appendTraceEvent(event: any) {

View file

@ -16,19 +16,32 @@
import { createGuid } from '../../../utils/utils'; import { createGuid } from '../../../utils/utils';
import * as trace from '../common/traceEvents'; import * as trace from '../common/traceEvents';
import { SnapshotRenderer } from '../../snapshot/snapshotRenderer'; import { ContextResources, ResourceSnapshot } from '../../snapshot/snapshotTypes';
import { ContextResources } from '../../snapshot/snapshot'; import { SnapshotStorage } from '../../snapshot/snapshotStorage';
export * as trace from '../common/traceEvents'; export * as trace from '../common/traceEvents';
export class TraceModel { export class TraceModel {
contextEntries = new Map<string, ContextEntry>(); contextEntries = new Map<string, ContextEntry>();
pageEntries = new Map<string, { contextEntry: ContextEntry, pageEntry: PageEntry }>(); pageEntries = new Map<string, { contextEntry: ContextEntry, pageEntry: PageEntry }>();
resourceById = new Map<string, trace.NetworkResourceTraceEvent>();
contextResources = new Map<string, ContextResources>(); contextResources = new Map<string, ContextResources>();
appendEvents(events: trace.TraceEvent[]) { appendEvents(events: trace.TraceEvent[], snapshotStorage: SnapshotStorage) {
for (const event of events) for (const event of events)
this.appendEvent(event); 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) { appendEvent(event: trace.TraceEvent) {
@ -54,9 +67,7 @@ export class TraceModel {
created: event, created: event,
destroyed: undefined as any, destroyed: undefined as any,
actions: [], actions: [],
resources: [],
interestingEvents: [], interestingEvents: [],
snapshotsByFrameId: {},
}; };
const contextEntry = this.contextEntries.get(event.contextId)!; const contextEntry = this.contextEntries.get(event.contextId)!;
this.pageEntries.set(event.pageId, { pageEntry, contextEntry }); this.pageEntries.set(event.pageId, { pageEntry, contextEntry });
@ -75,19 +86,11 @@ export class TraceModel {
const action: ActionEntry = { const action: ActionEntry = {
actionId, actionId,
action: event, action: event,
resources: pageEntry.resources, resources: []
}; };
pageEntry.resources = [];
pageEntry.actions.push(action); pageEntry.actions.push(action);
break; 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-opened':
case 'dialog-closed': case 'dialog-closed':
case 'navigation': case 'navigation':
@ -96,40 +99,12 @@ export class TraceModel {
pageEntry.interestingEvents.push(event); pageEntry.interestingEvents.push(event);
break; 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)!; const contextEntry = this.contextEntries.get(event.contextId)!;
contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp); contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
contextEntry.endTime = Math.max(contextEntry.endTime, 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 } { actionById(actionId: string): { context: ContextEntry, page: PageEntry, action: ActionEntry } {
const [contextId, pageId, actionIndex] = actionId.split('/'); const [contextId, pageId, actionIndex] = actionId.split('/');
const context = this.contextEntries.get(contextId)!; const context = this.contextEntries.get(contextId)!;
@ -151,27 +126,6 @@ export class TraceModel {
} }
return { contextEntry, pageEntry }; 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 = { export type ContextEntry = {
@ -190,14 +144,12 @@ export type PageEntry = {
destroyed: trace.PageDestroyedTraceEvent; destroyed: trace.PageDestroyedTraceEvent;
actions: ActionEntry[]; actions: ActionEntry[];
interestingEvents: InterestingPageEvent[]; interestingEvents: InterestingPageEvent[];
resources: trace.NetworkResourceTraceEvent[];
snapshotsByFrameId: { [key: string]: trace.FrameSnapshotTraceEvent[] };
} }
export type ActionEntry = { export type ActionEntry = {
actionId: string; actionId: string;
action: trace.ActionTraceEvent; 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']; 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 playwright from '../../../..';
import * as util from 'util'; import * as util from 'util';
import { TraceModel } from './traceModel'; import { TraceModel } from './traceModel';
import { NetworkResourceTraceEvent, TraceEvent } from '../common/traceEvents'; import { TraceEvent } from '../common/traceEvents';
import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer'; import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer';
import { SnapshotServer, SnapshotStorage } from '../../snapshot/snapshotServer'; import { SnapshotServer } from '../../snapshot/snapshotServer';
import { SnapshotRenderer } from '../../snapshot/snapshotRenderer'; import { PersistentSnapshotStorage } from '../../snapshot/snapshotStorage';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
@ -31,10 +31,10 @@ type TraceViewerDocument = {
model: TraceModel; model: TraceModel;
}; };
class TraceViewer implements SnapshotStorage { class TraceViewer {
private _document: TraceViewerDocument | undefined; private _document: TraceViewerDocument | undefined;
async load(traceDir: string) { async show(traceDir: string) {
const resourcesDir = path.join(traceDir, 'resources'); const resourcesDir = path.join(traceDir, 'resources');
const model = new TraceModel(); const model = new TraceModel();
this._document = { this._document = {
@ -42,19 +42,6 @@ class TraceViewer implements SnapshotStorage {
resourcesDir, 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 // Served by TraceServer
// - "/tracemodel" - json with trace model. // - "/tracemodel" - json with trace model.
// //
@ -70,8 +57,16 @@ class TraceViewer implements SnapshotStorage {
// - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources // - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources
// and translates them into "/resources/<resourceId>". // 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(); 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) => { const traceModelHandler: ServerRouteHandler = (request, response) => {
response.statusCode = 200; response.statusCode = 200;
@ -112,45 +107,15 @@ class TraceViewer implements SnapshotStorage {
server.routePrefix('/sha1/', sha1Handler); server.routePrefix('/sha1/', sha1Handler);
const urlPrefix = await server.start(); const urlPrefix = await server.start();
const browser = await playwright.chromium.launch({ headless: false });
const uiPage = await browser.newPage({ viewport: null }); const uiPage = await browser.newPage({ viewport: null });
uiPage.on('close', () => process.exit(0)); uiPage.on('close', () => process.exit(0));
await uiPage.goto(urlPrefix + '/traceviewer/traceViewer/index.html'); 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) { export async function showTraceViewer(traceDir: string) {
const traceViewer = new TraceViewer(); const traceViewer = new TraceViewer();
if (traceDir) await traceViewer.show(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),
};
} }

View file

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

View file

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

View file

@ -30,14 +30,7 @@ export const SnapshotTab: React.FunctionComponent<{
const [measure, ref] = useMeasure<HTMLDivElement>(); const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0); const [snapshotIndex, setSnapshotIndex] = React.useState(0);
let snapshots: { name: string, snapshotId?: string, snapshotTime?: number }[] = []; const snapshots = actionEntry ? (actionEntry.action.snapshots || []) : [];
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 { pageId, time } = selection || { pageId: undefined, time: 0 }; const { pageId, time } = selection || { pageId: undefined, time: 0 };
const iframeRef = React.createRef<HTMLIFrameElement>(); const iframeRef = React.createRef<HTMLIFrameElement>();
@ -45,18 +38,15 @@ export const SnapshotTab: React.FunctionComponent<{
if (!iframeRef.current) if (!iframeRef.current)
return; return;
// TODO: this logic is copied from SnapshotServer. Find a way to share. let snapshotUri = undefined;
let snapshotUrl = 'data:text/html,Snapshot is not available';
if (pageId) { if (pageId) {
snapshotUrl = `/snapshot/pageId/${pageId}/timestamp/${time}/main`; snapshotUri = `${pageId}?time=${time}`;
} else if (actionEntry) { } else if (actionEntry) {
const snapshot = snapshots[snapshotIndex]; const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotTime) if (snapshot && snapshot.snapshotName)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/timestamp/${snapshot.snapshotTime}/main`; snapshotUri = `${actionEntry.action.pageId}?name=${snapshot.snapshotName}`;
else if (snapshot && snapshot.snapshotId)
snapshotUrl = `/snapshot/pageId/${actionEntry.action.pageId!}/snapshotId/${snapshot.snapshotId}/main`;
} }
const snapshotUrl = snapshotUri ? `${window.location.origin}/snapshot/${snapshotUri}` : 'data:text/html,Snapshot is not available';
try { try {
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl); (iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl);
} catch (e) { } catch (e) {
@ -71,10 +61,10 @@ export const SnapshotTab: React.FunctionComponent<{
</div> </div>
}{!selection && snapshots.map((snapshot, index) => { }{!selection && snapshots.map((snapshot, index) => {
return <div return <div
key={snapshot.name} key={snapshot.title}
className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')} className={'snapshot-toggle' + (snapshotIndex === index ? ' toggled' : '')}
onClick={() => setSnapshotIndex(index)}> onClick={() => setSnapshotIndex(index)}>
{snapshot.name} {snapshot.title}
</div> </div>
}) })
}</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 { folio as baseFolio } from './fixtures';
import { InMemorySnapshotter } from '../lib/server/snapshot/inMemorySnapshotter'; import { InMemorySnapshotter } from '../lib/server/snapshot/inMemorySnapshotter';
import { HttpServer } from '../lib/utils/httpServer';
import { SnapshotServer } from '../lib/server/snapshot/snapshotServer';
type TestFixtures = { type TestFixtures = {
snapshotter: any; snapshotter: any;
snapshotPort: number;
}; };
export const fixtures = baseFolio.extend<TestFixtures>(); export const fixtures = baseFolio.extend<TestFixtures>();
@ -29,6 +32,15 @@ fixtures.snapshotter.init(async ({ context, toImpl }, runTest) => {
await snapshotter.dispose(); 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(); const { it, describe, expect } = fixtures.build();
describe('snapshots', (suite, { mode }) => { describe('snapshots', (suite, { mode }) => {
@ -122,12 +134,53 @@ describe('snapshots', (suite, { mode }) => {
const { sha1 } = resources[cssHref]; const { sha1 } = resources[cssHref];
expect(snapshotter.resourceContent(sha1).toString()).toBe('button { color: blue; }'); 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) { function distillSnapshot(snapshot) {
const { html } = snapshot.render(); const { html } = snapshot.render();
return html return html
.replace(/<script>function snapshotScript[.\s\S]*/, '') .replace(/<script>[.\s\S]+<\/script>/, '')
.replace(/<BASE href="about:blank">/, '') .replace(/<BASE href="about:blank">/, '')
.replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '') .replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '')
.replace(/<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. // 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/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/'] = ['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. // 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/remote/'] = ['src/client/', 'src/debug/', 'src/dispatchers/', 'src/server/', 'src/server/supplements/', 'src/server/electron/', 'src/server/trace/'];
DEPS['src/service.ts'] = ['src/remote/']; DEPS['src/service.ts'] = ['src/remote/'];