chore: make trace model a class (#5600)
This commit is contained in:
parent
f71bf9a42a
commit
6bf3fe8432
120
src/server/trace/viewer/frameSnapshot.ts
Normal file
120
src/server/trace/viewer/frameSnapshot.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as trace from '../common/traceEvents';
|
||||||
|
import { ContextEntry } from './traceModel';
|
||||||
|
export * as trace from '../common/traceEvents';
|
||||||
|
|
||||||
|
export type SerializedFrameSnapshot = {
|
||||||
|
html: string;
|
||||||
|
resourcesByUrl: { [key: string]: { resourceId: string, frameId: string }[] };
|
||||||
|
overridenUrls: { [key: string]: boolean };
|
||||||
|
resourceOverrides: { [key: string]: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FrameSnapshot {
|
||||||
|
private _snapshots: trace.FrameSnapshotTraceEvent[];
|
||||||
|
private _index: number;
|
||||||
|
contextEntry: ContextEntry;
|
||||||
|
|
||||||
|
constructor(contextEntry: ContextEntry, events: trace.FrameSnapshotTraceEvent[], index: number) {
|
||||||
|
this.contextEntry = contextEntry;
|
||||||
|
this._snapshots = events;
|
||||||
|
this._index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
traceEvent(): trace.FrameSnapshotTraceEvent {
|
||||||
|
return this._snapshots[this._index];
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(): SerializedFrameSnapshot {
|
||||||
|
const visit = (n: trace.NodeSnapshot, snapshotIndex: number): string => {
|
||||||
|
// Text node.
|
||||||
|
if (typeof n === 'string')
|
||||||
|
return escapeText(n);
|
||||||
|
|
||||||
|
if (!(n as any)._string) {
|
||||||
|
if (Array.isArray(n[0])) {
|
||||||
|
// Node reference.
|
||||||
|
const referenceIndex = snapshotIndex - n[0][0];
|
||||||
|
if (referenceIndex >= 0 && referenceIndex < snapshotIndex) {
|
||||||
|
const nodes = snapshotNodes(this._snapshots[referenceIndex].snapshot);
|
||||||
|
const nodeIndex = n[0][1];
|
||||||
|
if (nodeIndex >= 0 && nodeIndex < nodes.length)
|
||||||
|
(n as any)._string = visit(nodes[nodeIndex], referenceIndex);
|
||||||
|
}
|
||||||
|
} else if (typeof n[0] === 'string') {
|
||||||
|
// Element node.
|
||||||
|
const builder: string[] = [];
|
||||||
|
builder.push('<', n[0]);
|
||||||
|
for (const [attr, value] of Object.entries(n[1] || {}))
|
||||||
|
builder.push(' ', attr, '="', escapeAttribute(value as string), '"');
|
||||||
|
builder.push('>');
|
||||||
|
for (let i = 2; i < n.length; i++)
|
||||||
|
builder.push(visit(n[i], snapshotIndex));
|
||||||
|
if (!autoClosing.has(n[0]))
|
||||||
|
builder.push('</', n[0], '>');
|
||||||
|
(n as any)._string = builder.join('');
|
||||||
|
} else {
|
||||||
|
// Why are we here? Let's not throw, just in case.
|
||||||
|
(n as any)._string = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (n as any)._string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = this._snapshots[this._index].snapshot;
|
||||||
|
let html = visit(snapshot.html, this._index);
|
||||||
|
if (snapshot.doctype)
|
||||||
|
html = `<!DOCTYPE ${snapshot.doctype}>` + html;
|
||||||
|
html += `<script>${this.contextEntry.created.snapshotScript}</script>`;
|
||||||
|
|
||||||
|
const resourcesByUrl = this.contextEntry.resourcesByUrl;
|
||||||
|
const overridenUrls = this.contextEntry.overridenUrls;
|
||||||
|
const resourceOverrides: any = {};
|
||||||
|
for (const o of this._snapshots[this._index].snapshot.resourceOverrides)
|
||||||
|
resourceOverrides[o.url] = o.sha1;
|
||||||
|
return { html, resourcesByUrl, overridenUrls, resourceOverrides };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
||||||
|
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||||
|
|
||||||
|
function escapeAttribute(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
|
||||||
|
}
|
||||||
|
function escapeText(s: string): string {
|
||||||
|
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotNodes(snapshot: trace.FrameSnapshot): trace.NodeSnapshot[] {
|
||||||
|
if (!(snapshot as any)._nodes) {
|
||||||
|
const nodes: trace.NodeSnapshot[] = [];
|
||||||
|
const visit = (n: trace.NodeSnapshot) => {
|
||||||
|
if (typeof n === 'string') {
|
||||||
|
nodes.push(n);
|
||||||
|
} else if (typeof n[0] === 'string') {
|
||||||
|
for (let i = 2; i < n.length; i++)
|
||||||
|
visit(n[i]);
|
||||||
|
nodes.push(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(snapshot.html);
|
||||||
|
(snapshot as any)._nodes = nodes;
|
||||||
|
}
|
||||||
|
return (snapshot as any)._nodes;
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as playwright from '../../../..';
|
import * as playwright from '../../../..';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import { actionById, ActionEntry, ContextEntry, TraceModel } from './traceModel';
|
import { ActionEntry, ContextEntry, TraceModel } from './traceModel';
|
||||||
import { SnapshotServer } from './snapshotServer';
|
import { SnapshotServer } from './snapshotServer';
|
||||||
|
|
||||||
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
|
||||||
|
|
@ -40,7 +40,7 @@ export class ScreenshotGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
generateScreenshot(actionId: string): Promise<Buffer | undefined> {
|
generateScreenshot(actionId: string): Promise<Buffer | undefined> {
|
||||||
const { context, action } = actionById(this._traceModel, actionId);
|
const { context, action } = this._traceModel.actionById(actionId);
|
||||||
if (!this._rendering.has(action)) {
|
if (!this._rendering.has(action)) {
|
||||||
this._rendering.set(action, this._render(context, action).then(body => {
|
this._rendering.set(action, this._render(context, action).then(body => {
|
||||||
this._rendering.delete(action);
|
this._rendering.delete(action);
|
||||||
|
|
|
||||||
|
|
@ -19,29 +19,18 @@ import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import querystring from 'querystring';
|
import querystring from 'querystring';
|
||||||
import type { TraceModel } from './traceModel';
|
import type { TraceModel } from './traceModel';
|
||||||
import * as trace from '../common/traceEvents';
|
|
||||||
import { TraceServer } from './traceServer';
|
import { TraceServer } from './traceServer';
|
||||||
|
|
||||||
export class SnapshotServer {
|
export class SnapshotServer {
|
||||||
private _resourcesDir: string | undefined;
|
private _resourcesDir: string | undefined;
|
||||||
private _server: TraceServer;
|
private _server: TraceServer;
|
||||||
private _resourceById: Map<string, trace.NetworkResourceTraceEvent>;
|
|
||||||
private _traceModel: TraceModel;
|
private _traceModel: TraceModel;
|
||||||
|
|
||||||
constructor(server: TraceServer, traceModel: TraceModel, resourcesDir: string | undefined) {
|
constructor(server: TraceServer, traceModel: TraceModel, resourcesDir: string | undefined) {
|
||||||
this._resourcesDir = resourcesDir;
|
this._resourcesDir = resourcesDir;
|
||||||
this._server = server;
|
this._server = server;
|
||||||
|
|
||||||
this._resourceById = new Map();
|
|
||||||
this._traceModel = traceModel;
|
this._traceModel = traceModel;
|
||||||
for (const contextEntry of traceModel.contexts) {
|
|
||||||
for (const pageEntry of contextEntry.pages) {
|
|
||||||
for (const action of pageEntry.actions)
|
|
||||||
action.resources.forEach(r => this._resourceById.set(r.resourceId, r));
|
|
||||||
pageEntry.resources.forEach(r => this._resourceById.set(r.resourceId, r));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this), true);
|
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this), true);
|
||||||
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
|
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
|
||||||
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
|
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
|
||||||
|
|
@ -113,86 +102,6 @@ export class SnapshotServer {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _frameSnapshotData(parsed: { pageId: string, frameId: string, snapshotId?: string, timestamp?: number }) {
|
|
||||||
let contextEntry;
|
|
||||||
let pageEntry;
|
|
||||||
for (const c of this._traceModel.contexts) {
|
|
||||||
for (const p of c.pages) {
|
|
||||||
if (p.created.pageId === parsed.pageId) {
|
|
||||||
contextEntry = c;
|
|
||||||
pageEntry = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!contextEntry || !pageEntry)
|
|
||||||
return { html: '' };
|
|
||||||
|
|
||||||
const frameSnapshots = pageEntry.snapshotsByFrameId[parsed.frameId] || [];
|
|
||||||
let snapshotIndex = -1;
|
|
||||||
for (let index = 0; index < frameSnapshots.length; index++) {
|
|
||||||
const current = snapshotIndex === -1 ? undefined : frameSnapshots[snapshotIndex];
|
|
||||||
const snapshot = frameSnapshots[index];
|
|
||||||
// Prefer snapshot with exact id.
|
|
||||||
const exactMatch = parsed.snapshotId && snapshot.snapshotId === parsed.snapshotId;
|
|
||||||
const currentExactMatch = current && parsed.snapshotId && current.snapshotId === parsed.snapshotId;
|
|
||||||
// If not available, prefer the latest snapshot before the timestamp.
|
|
||||||
const timestampMatch = parsed.timestamp && snapshot.timestamp <= parsed.timestamp;
|
|
||||||
if (exactMatch || (timestampMatch && !currentExactMatch))
|
|
||||||
snapshotIndex = index;
|
|
||||||
}
|
|
||||||
let html = this._serializeSnapshot(frameSnapshots, snapshotIndex);
|
|
||||||
html += `<script>${contextEntry.created.snapshotScript}</script>`;
|
|
||||||
const resourcesByUrl = contextEntry.resourcesByUrl;
|
|
||||||
const overridenUrls = contextEntry.overridenUrls;
|
|
||||||
const resourceOverrides: any = {};
|
|
||||||
for (const o of frameSnapshots[snapshotIndex].snapshot.resourceOverrides)
|
|
||||||
resourceOverrides[o.url] = o.sha1;
|
|
||||||
return { html, resourcesByUrl, overridenUrls, resourceOverrides };
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serializeSnapshot(snapshots: trace.FrameSnapshotTraceEvent[], initialSnapshotIndex: number): string {
|
|
||||||
const visit = (n: trace.NodeSnapshot, snapshotIndex: number): string => {
|
|
||||||
// Text node.
|
|
||||||
if (typeof n === 'string')
|
|
||||||
return escapeText(n);
|
|
||||||
|
|
||||||
if (!(n as any)._string) {
|
|
||||||
if (Array.isArray(n[0])) {
|
|
||||||
// Node reference.
|
|
||||||
const referenceIndex = snapshotIndex - n[0][0];
|
|
||||||
if (referenceIndex >= 0 && referenceIndex < snapshotIndex) {
|
|
||||||
const nodes = snapshotNodes(snapshots[referenceIndex].snapshot);
|
|
||||||
const nodeIndex = n[0][1];
|
|
||||||
if (nodeIndex >= 0 && nodeIndex < nodes.length)
|
|
||||||
(n as any)._string = visit(nodes[nodeIndex], referenceIndex);
|
|
||||||
}
|
|
||||||
} else if (typeof n[0] === 'string') {
|
|
||||||
// Element node.
|
|
||||||
const builder: string[] = [];
|
|
||||||
builder.push('<', n[0]);
|
|
||||||
for (const [attr, value] of Object.entries(n[1] || {}))
|
|
||||||
builder.push(' ', attr, '="', escapeAttribute(value as string), '"');
|
|
||||||
builder.push('>');
|
|
||||||
for (let i = 2; i < n.length; i++)
|
|
||||||
builder.push(visit(n[i], snapshotIndex));
|
|
||||||
if (!autoClosing.has(n[0]))
|
|
||||||
builder.push('</', n[0], '>');
|
|
||||||
(n as any)._string = builder.join('');
|
|
||||||
} else {
|
|
||||||
// Why are we here? Let's not throw, just in case.
|
|
||||||
(n as any)._string = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (n as any)._string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const snapshot = snapshots[initialSnapshotIndex].snapshot;
|
|
||||||
let html = visit(snapshot.html, initialSnapshotIndex);
|
|
||||||
if (snapshot.doctype)
|
|
||||||
html = `<!DOCTYPE ${snapshot.doctype}>` + html;
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _serveServiceWorker(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
private _serveServiceWorker(request: http.IncomingMessage, response: http.ServerResponse): boolean {
|
||||||
function serviceWorkerMain(self: any /* ServiceWorkerGlobalScope */) {
|
function serviceWorkerMain(self: any /* ServiceWorkerGlobalScope */) {
|
||||||
const pageToResourcesByUrl = new Map<string, { [key: string]: { resourceId: string, frameId: string }[] }>();
|
const pageToResourcesByUrl = new Map<string, { [key: string]: { resourceId: string, frameId: string }[] }>();
|
||||||
|
|
@ -261,7 +170,7 @@ export class SnapshotServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.mode === 'navigate') {
|
if (request.mode === 'navigate') {
|
||||||
const htmlResponse = await fetch(`/snapshot-data?pageId=${parsed.pageId}&snapshotId=${parsed.snapshotId}×tamp=${parsed.timestamp}&frameId=${parsed.frameId}`);
|
const htmlResponse = await fetch(`/snapshot-data?pageId=${parsed.pageId}&snapshotId=${parsed.snapshotId || ''}×tamp=${parsed.timestamp || ''}&frameId=${parsed.frameId || ''}`);
|
||||||
const { html, resourcesByUrl, overridenUrls, resourceOverrides } = await htmlResponse.json();
|
const { html, resourcesByUrl, overridenUrls, resourceOverrides } = await htmlResponse.json();
|
||||||
if (!html)
|
if (!html)
|
||||||
return respondNotAvailable();
|
return respondNotAvailable();
|
||||||
|
|
@ -320,8 +229,11 @@ export class SnapshotServer {
|
||||||
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 = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1));
|
const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1));
|
||||||
const snapshotData = this._frameSnapshotData(parsed as any);
|
const snapshot = parsed.snapshotId ?
|
||||||
|
this._traceModel.findSnapshotById(parsed.pageId, parsed.frameId, parsed.snapshotId) :
|
||||||
|
this._traceModel.findSnapshotByTime(parsed.pageId, parsed.frameId, parsed.timestamp!);
|
||||||
|
const snapshotData: any = snapshot ? snapshot.serialize() : { html: '' };
|
||||||
response.end(JSON.stringify(snapshotData));
|
response.end(JSON.stringify(snapshotData));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -351,7 +263,7 @@ export class SnapshotServer {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resource = this._resourceById.get(resourceId);
|
const resource = this._traceModel.resourceById.get(resourceId);
|
||||||
if (!resource)
|
if (!resource)
|
||||||
return false;
|
return false;
|
||||||
const sha1 = overrideSha1 || resource.responseSha1;
|
const sha1 = overrideSha1 || resource.responseSha1;
|
||||||
|
|
@ -379,31 +291,3 @@ export class SnapshotServer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
|
|
||||||
const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
|
||||||
|
|
||||||
function escapeAttribute(s: string): string {
|
|
||||||
return s.replace(/[&<>"']/ug, char => (escaped as any)[char]);
|
|
||||||
}
|
|
||||||
function escapeText(s: string): string {
|
|
||||||
return s.replace(/[&<]/ug, char => (escaped as any)[char]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotNodes(snapshot: trace.FrameSnapshot): trace.NodeSnapshot[] {
|
|
||||||
if (!(snapshot as any)._nodes) {
|
|
||||||
const nodes: trace.NodeSnapshot[] = [];
|
|
||||||
const visit = (n: trace.NodeSnapshot) => {
|
|
||||||
if (typeof n === 'string') {
|
|
||||||
nodes.push(n);
|
|
||||||
} else if (typeof n[0] === 'string') {
|
|
||||||
for (let i = 2; i < n.length; i++)
|
|
||||||
visit(n[i]);
|
|
||||||
nodes.push(n);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(snapshot.html);
|
|
||||||
(snapshot as any)._nodes = nodes;
|
|
||||||
}
|
|
||||||
return (snapshot as any)._nodes;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,170 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createGuid } from '../../../utils/utils';
|
||||||
import * as trace from '../common/traceEvents';
|
import * as trace from '../common/traceEvents';
|
||||||
|
import { FrameSnapshot } from './frameSnapshot';
|
||||||
export * as trace from '../common/traceEvents';
|
export * as trace from '../common/traceEvents';
|
||||||
|
|
||||||
export type TraceModel = {
|
export class TraceModel {
|
||||||
contexts: ContextEntry[];
|
contextEntries = new Map<string, ContextEntry>();
|
||||||
};
|
pageEntries = new Map<string, { contextEntry: ContextEntry, pageEntry: PageEntry }>();
|
||||||
|
resourceById = new Map<string, trace.NetworkResourceTraceEvent>();
|
||||||
|
|
||||||
|
appendEvents(events: trace.TraceEvent[]) {
|
||||||
|
for (const event of events)
|
||||||
|
this.appendEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendEvent(event: trace.TraceEvent) {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'context-created': {
|
||||||
|
this.contextEntries.set(event.contextId, {
|
||||||
|
name: event.debugName || createGuid(),
|
||||||
|
startTime: Number.MAX_VALUE,
|
||||||
|
endTime: Number.MIN_VALUE,
|
||||||
|
created: event,
|
||||||
|
destroyed: undefined as any,
|
||||||
|
pages: [],
|
||||||
|
resourcesByUrl: {},
|
||||||
|
overridenUrls: {}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'context-destroyed': {
|
||||||
|
this.contextEntries.get(event.contextId)!.destroyed = event;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'page-created': {
|
||||||
|
const pageEntry: PageEntry = {
|
||||||
|
created: event,
|
||||||
|
destroyed: undefined as any,
|
||||||
|
actions: [],
|
||||||
|
resources: [],
|
||||||
|
interestingEvents: [],
|
||||||
|
snapshotsByFrameId: {},
|
||||||
|
};
|
||||||
|
const contextEntry = this.contextEntries.get(event.contextId)!;
|
||||||
|
this.pageEntries.set(event.pageId, { pageEntry, contextEntry });
|
||||||
|
contextEntry.pages.push(pageEntry);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'page-destroyed': {
|
||||||
|
this.pageEntries.get(event.pageId)!.pageEntry.destroyed = event;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'action': {
|
||||||
|
if (!kInterestingActions.includes(event.method))
|
||||||
|
break;
|
||||||
|
const { pageEntry } = this.pageEntries.get(event.pageId!)!;
|
||||||
|
const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length;
|
||||||
|
const action: ActionEntry = {
|
||||||
|
actionId,
|
||||||
|
action: event,
|
||||||
|
thumbnailUrl: `/action-preview/${actionId}.png`,
|
||||||
|
resources: pageEntry.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':
|
||||||
|
case 'load': {
|
||||||
|
const { pageEntry } = this.pageEntries.get(event.pageId)!;
|
||||||
|
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);
|
||||||
|
const contextEntry = this.contextEntries.get(event.contextId)!;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
contextEntry.overridenUrls[override.url] = true;
|
||||||
|
}
|
||||||
|
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 contextEntry = this.contextEntries.get(event.contextId)!;
|
||||||
|
let responseEvents = contextEntry.resourcesByUrl[event.url];
|
||||||
|
if (!responseEvents) {
|
||||||
|
responseEvents = [];
|
||||||
|
contextEntry.resourcesByUrl[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)!;
|
||||||
|
const page = context.pages.find(entry => entry.created.pageId === pageId)!;
|
||||||
|
const action = page.actions[+actionIndex];
|
||||||
|
return { context, page, action };
|
||||||
|
}
|
||||||
|
|
||||||
|
findPage(pageId: string): { contextEntry: ContextEntry | undefined, pageEntry: PageEntry | undefined } {
|
||||||
|
let contextEntry;
|
||||||
|
let pageEntry;
|
||||||
|
for (const c of this.contextEntries.values()) {
|
||||||
|
for (const p of c.pages) {
|
||||||
|
if (p.created.pageId === pageId) {
|
||||||
|
contextEntry = c;
|
||||||
|
pageEntry = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { contextEntry, pageEntry };
|
||||||
|
}
|
||||||
|
|
||||||
|
findSnapshotById(pageId: string, frameId: string, snapshotId: string): FrameSnapshot | undefined {
|
||||||
|
const { pageEntry, contextEntry } = this.pageEntries.get(pageId)!;
|
||||||
|
const frameSnapshots = pageEntry.snapshotsByFrameId[frameId];
|
||||||
|
for (let index = 0; index < frameSnapshots.length; index++) {
|
||||||
|
if (frameSnapshots[index].snapshotId === snapshotId)
|
||||||
|
return new FrameSnapshot(contextEntry, frameSnapshots, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findSnapshotByTime(pageId: string, frameId: string, timestamp: number): FrameSnapshot | 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 FrameSnapshot(contextEntry, frameSnapshots, snapshotIndex) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ContextEntry = {
|
export type ContextEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
filePath: string;
|
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
created: trace.ContextCreatedTraceEvent;
|
created: trace.ContextCreatedTraceEvent;
|
||||||
|
|
@ -33,17 +187,11 @@ export type ContextEntry = {
|
||||||
overridenUrls: { [key: string]: boolean };
|
overridenUrls: { [key: string]: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VideoEntry = {
|
|
||||||
video: trace.PageVideoTraceEvent;
|
|
||||||
videoId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InterestingPageEvent = trace.DialogOpenedEvent | trace.DialogClosedEvent | trace.NavigationEvent | trace.LoadEvent;
|
export type InterestingPageEvent = trace.DialogOpenedEvent | trace.DialogClosedEvent | trace.NavigationEvent | trace.LoadEvent;
|
||||||
|
|
||||||
export type PageEntry = {
|
export type PageEntry = {
|
||||||
created: trace.PageCreatedTraceEvent;
|
created: trace.PageCreatedTraceEvent;
|
||||||
destroyed: trace.PageDestroyedTraceEvent;
|
destroyed: trace.PageDestroyedTraceEvent;
|
||||||
video?: VideoEntry;
|
|
||||||
actions: ActionEntry[];
|
actions: ActionEntry[];
|
||||||
interestingEvents: InterestingPageEvent[];
|
interestingEvents: InterestingPageEvent[];
|
||||||
resources: trace.NetworkResourceTraceEvent[];
|
resources: trace.NetworkResourceTraceEvent[];
|
||||||
|
|
@ -57,153 +205,4 @@ export type ActionEntry = {
|
||||||
resources: trace.NetworkResourceTraceEvent[];
|
resources: trace.NetworkResourceTraceEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VideoMetaInfo = {
|
|
||||||
frames: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
fps: number;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
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'];
|
||||||
|
|
||||||
export function readTraceFile(events: trace.TraceEvent[], traceModel: TraceModel, filePath: string) {
|
|
||||||
const contextEntries = new Map<string, ContextEntry>();
|
|
||||||
const pageEntries = new Map<string, PageEntry>();
|
|
||||||
for (const event of events) {
|
|
||||||
switch (event.type) {
|
|
||||||
case 'context-created': {
|
|
||||||
contextEntries.set(event.contextId, {
|
|
||||||
filePath,
|
|
||||||
name: event.debugName || filePath.substring(filePath.lastIndexOf('/') + 1),
|
|
||||||
startTime: Number.MAX_VALUE,
|
|
||||||
endTime: Number.MIN_VALUE,
|
|
||||||
created: event,
|
|
||||||
destroyed: undefined as any,
|
|
||||||
pages: [],
|
|
||||||
resourcesByUrl: {},
|
|
||||||
overridenUrls: {}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'context-destroyed': {
|
|
||||||
contextEntries.get(event.contextId)!.destroyed = event;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'page-created': {
|
|
||||||
const pageEntry: PageEntry = {
|
|
||||||
created: event,
|
|
||||||
destroyed: undefined as any,
|
|
||||||
actions: [],
|
|
||||||
resources: [],
|
|
||||||
interestingEvents: [],
|
|
||||||
snapshotsByFrameId: {},
|
|
||||||
};
|
|
||||||
pageEntries.set(event.pageId, pageEntry);
|
|
||||||
contextEntries.get(event.contextId)!.pages.push(pageEntry);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'page-destroyed': {
|
|
||||||
pageEntries.get(event.pageId)!.destroyed = event;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'page-video': {
|
|
||||||
const pageEntry = pageEntries.get(event.pageId)!;
|
|
||||||
pageEntry.video = { video: event, videoId: event.contextId + '/' + event.pageId };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'action': {
|
|
||||||
if (!kInterestingActions.includes(event.method))
|
|
||||||
break;
|
|
||||||
const pageEntry = pageEntries.get(event.pageId!)!;
|
|
||||||
const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length;
|
|
||||||
const action: ActionEntry = {
|
|
||||||
actionId,
|
|
||||||
action: event,
|
|
||||||
thumbnailUrl: `/action-preview/${actionId}.png`,
|
|
||||||
resources: pageEntry.resources,
|
|
||||||
};
|
|
||||||
pageEntry.resources = [];
|
|
||||||
pageEntry.actions.push(action);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'resource': {
|
|
||||||
const pageEntry = pageEntries.get(event.pageId!)!;
|
|
||||||
const action = pageEntry.actions[pageEntry.actions.length - 1];
|
|
||||||
if (action)
|
|
||||||
action.resources.push(event);
|
|
||||||
else
|
|
||||||
pageEntry.resources.push(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'dialog-opened':
|
|
||||||
case 'dialog-closed':
|
|
||||||
case 'navigation':
|
|
||||||
case 'load': {
|
|
||||||
const pageEntry = pageEntries.get(event.pageId)!;
|
|
||||||
pageEntry.interestingEvents.push(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'snapshot': {
|
|
||||||
const pageEntry = pageEntries.get(event.pageId!)!;
|
|
||||||
if (!(event.frameId in pageEntry.snapshotsByFrameId))
|
|
||||||
pageEntry.snapshotsByFrameId[event.frameId] = [];
|
|
||||||
pageEntry.snapshotsByFrameId[event.frameId]!.push(event);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contextEntry = contextEntries.get(event.contextId)!;
|
|
||||||
contextEntry.startTime = Math.min(contextEntry.startTime, event.timestamp);
|
|
||||||
contextEntry.endTime = Math.max(contextEntry.endTime, event.timestamp);
|
|
||||||
}
|
|
||||||
traceModel.contexts.push(...contextEntries.values());
|
|
||||||
preprocessModel(traceModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
function preprocessModel(traceModel: TraceModel) {
|
|
||||||
for (const contextEntry of traceModel.contexts) {
|
|
||||||
const appendResource = (event: trace.NetworkResourceTraceEvent) => {
|
|
||||||
let responseEvents = contextEntry.resourcesByUrl[event.url];
|
|
||||||
if (!responseEvents) {
|
|
||||||
responseEvents = [];
|
|
||||||
contextEntry.resourcesByUrl[event.url] = responseEvents;
|
|
||||||
}
|
|
||||||
responseEvents.push({ frameId: event.frameId, resourceId: event.resourceId });
|
|
||||||
};
|
|
||||||
for (const pageEntry of contextEntry.pages) {
|
|
||||||
for (const action of pageEntry.actions)
|
|
||||||
action.resources.forEach(appendResource);
|
|
||||||
pageEntry.resources.forEach(appendResource);
|
|
||||||
for (const snapshots of Object.values(pageEntry.snapshotsByFrameId)) {
|
|
||||||
for (let i = 0; i < snapshots.length; ++i) {
|
|
||||||
const snapshot = snapshots[i];
|
|
||||||
for (const override of snapshot.snapshot.resourceOverrides) {
|
|
||||||
if (override.ref) {
|
|
||||||
const refOverride = snapshots[i - override.ref]?.snapshot.resourceOverrides.find(o => o.url === override.url);
|
|
||||||
override.sha1 = refOverride?.sha1;
|
|
||||||
delete override.ref;
|
|
||||||
}
|
|
||||||
contextEntry.overridenUrls[override.url] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function actionById(traceModel: TraceModel, actionId: string): { context: ContextEntry, page: PageEntry, action: ActionEntry } {
|
|
||||||
const [contextId, pageId, actionIndex] = actionId.split('/');
|
|
||||||
const context = traceModel.contexts.find(entry => entry.created.contextId === contextId)!;
|
|
||||||
const page = context.pages.find(entry => entry.created.pageId === pageId)!;
|
|
||||||
const action = page.actions[+actionIndex];
|
|
||||||
return { context, page, action };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function videoById(traceModel: TraceModel, videoId: string): { context: ContextEntry, page: PageEntry } {
|
|
||||||
const [contextId, pageId] = videoId.split('/');
|
|
||||||
const context = traceModel.contexts.find(entry => entry.created.contextId === contextId)!;
|
|
||||||
const page = context.pages.find(entry => entry.created.pageId === pageId)!;
|
|
||||||
return { context, page };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ export class TraceServer {
|
||||||
const traceModelHandler: ServerRouteHandler = (request, response) => {
|
const traceModelHandler: ServerRouteHandler = (request, response) => {
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
response.setHeader('Content-Type', 'application/json');
|
response.setHeader('Content-Type', 'application/json');
|
||||||
response.end(JSON.stringify(this._traceModel));
|
response.end(JSON.stringify(Array.from(this._traceModel.contextEntries.values())));
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
this.routePath('/tracemodel', traceModelHandler);
|
this.routePath('/contexts', traceModelHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
routePrefix(prefix: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
|
routePrefix(prefix: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import path from 'path';
|
||||||
import * as playwright from '../../../..';
|
import * as playwright from '../../../..';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import { ScreenshotGenerator } from './screenshotGenerator';
|
import { ScreenshotGenerator } from './screenshotGenerator';
|
||||||
import { readTraceFile, TraceModel } from './traceModel';
|
import { TraceModel } from './traceModel';
|
||||||
import type { TraceEvent } from '../common/traceEvents';
|
import type { TraceEvent } from '../common/traceEvents';
|
||||||
import { SnapshotServer } from './snapshotServer';
|
import { SnapshotServer } from './snapshotServer';
|
||||||
import { ServerRouteHandler, TraceServer } from './traceServer';
|
import { ServerRouteHandler, TraceServer } from './traceServer';
|
||||||
|
|
@ -31,41 +31,14 @@ type TraceViewerDocument = {
|
||||||
model: TraceModel;
|
model: TraceModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyModel: TraceModel = {
|
const emptyModel: TraceModel = new TraceModel();
|
||||||
contexts: [
|
|
||||||
{
|
|
||||||
startTime: 0,
|
|
||||||
endTime: 1,
|
|
||||||
created: {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
type: 'context-created',
|
|
||||||
browserName: 'none',
|
|
||||||
contextId: '<empty>',
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
isMobile: false,
|
|
||||||
viewportSize: { width: 800, height: 600 },
|
|
||||||
snapshotScript: '',
|
|
||||||
},
|
|
||||||
destroyed: {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
type: 'context-destroyed',
|
|
||||||
contextId: '<empty>',
|
|
||||||
},
|
|
||||||
name: '<empty>',
|
|
||||||
filePath: '',
|
|
||||||
pages: [],
|
|
||||||
resourcesByUrl: {},
|
|
||||||
overridenUrls: {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
class TraceViewer {
|
class TraceViewer {
|
||||||
private _document: TraceViewerDocument | undefined;
|
private _document: TraceViewerDocument | undefined;
|
||||||
|
|
||||||
async load(traceDir: string) {
|
async load(traceDir: string) {
|
||||||
const resourcesDir = path.join(traceDir, 'resources');
|
const resourcesDir = path.join(traceDir, 'resources');
|
||||||
const model = { contexts: [] };
|
const model = new TraceModel();
|
||||||
this._document = {
|
this._document = {
|
||||||
model,
|
model,
|
||||||
resourcesDir,
|
resourcesDir,
|
||||||
|
|
@ -77,7 +50,7 @@ class TraceViewer {
|
||||||
const filePath = path.join(traceDir, name);
|
const filePath = path.join(traceDir, name);
|
||||||
const traceContent = await fsReadFileAsync(filePath, 'utf8');
|
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[];
|
const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)) as TraceEvent[];
|
||||||
readTraceFile(events, model, filePath);
|
model.appendEvents(events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,6 @@ import '../common.css';
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
const traceModel = await fetch('/tracemodel').then(response => response.json());
|
const contexts = await fetch('/contexts').then(response => response.json());
|
||||||
ReactDOM.render(<Workbench traceModel={traceModel} />, document.querySelector('#root'));
|
ReactDOM.render(<Workbench contexts={contexts} />, document.querySelector('#root'));
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ActionEntry, TraceModel } from '../../../server/trace/viewer/traceModel';
|
import { ActionEntry, ContextEntry, TraceModel } from '../../../server/trace/viewer/traceModel';
|
||||||
import { ActionList } from './actionList';
|
import { ActionList } from './actionList';
|
||||||
import { TabbedPane } from './tabbedPane';
|
import { TabbedPane } from './tabbedPane';
|
||||||
import { Timeline } from './timeline';
|
import { Timeline } from './timeline';
|
||||||
|
|
@ -27,9 +27,9 @@ import { SnapshotTab } from './snapshotTab';
|
||||||
import { LogsTab } from './logsTab';
|
import { LogsTab } from './logsTab';
|
||||||
|
|
||||||
export const Workbench: React.FunctionComponent<{
|
export const Workbench: React.FunctionComponent<{
|
||||||
traceModel: TraceModel,
|
contexts: ContextEntry[],
|
||||||
}> = ({ traceModel }) => {
|
}> = ({ contexts }) => {
|
||||||
const [context, setContext] = React.useState(traceModel.contexts[0]);
|
const [context, setContext] = React.useState(contexts[0]);
|
||||||
const [selectedAction, setSelectedAction] = React.useState<ActionEntry | undefined>();
|
const [selectedAction, setSelectedAction] = React.useState<ActionEntry | undefined>();
|
||||||
const [highlightedAction, setHighlightedAction] = React.useState<ActionEntry | undefined>();
|
const [highlightedAction, setHighlightedAction] = React.useState<ActionEntry | undefined>();
|
||||||
const [selectedTime, setSelectedTime] = React.useState<number | undefined>();
|
const [selectedTime, setSelectedTime] = React.useState<number | undefined>();
|
||||||
|
|
@ -51,7 +51,7 @@ export const Workbench: React.FunctionComponent<{
|
||||||
<div className='product'>Playwright</div>
|
<div className='product'>Playwright</div>
|
||||||
<div className='spacer'></div>
|
<div className='spacer'></div>
|
||||||
<ContextSelector
|
<ContextSelector
|
||||||
contexts={traceModel.contexts}
|
contexts={contexts}
|
||||||
context={context}
|
context={context}
|
||||||
onChange={context => {
|
onChange={context => {
|
||||||
setContext(context);
|
setContext(context);
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ DEPS['src/utils/'] = ['src/common/'];
|
||||||
|
|
||||||
// Trace viewer
|
// Trace viewer
|
||||||
DEPS['src/server/trace/recorder/'] = ['src/server/trace/common/', ...DEPS['src/server/']];
|
DEPS['src/server/trace/recorder/'] = ['src/server/trace/common/', ...DEPS['src/server/']];
|
||||||
DEPS['src/server/trace/viewer/'] = ['src/server/trace/common/'];
|
DEPS['src/server/trace/viewer/'] = ['src/server/trace/common/', ...DEPS['src/server/']];
|
||||||
|
|
||||||
checkDeps().catch(e => {
|
checkDeps().catch(e => {
|
||||||
console.error(e && e.stack ? e.stack : e);
|
console.error(e && e.stack ? e.stack : e);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue