/** * 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 '../../server/trace/common/traceEvents'; import type { ResourceSnapshot } from '../../server/trace/common/snapshotTypes'; import { BaseSnapshotStorage } from './snapshotStorage'; import type zip from '@zip.js/zip.js'; // @ts-ignore self.importScripts('zip.min.js'); const zipjs = (self as any).zip; export class TraceModel { contextEntry: ContextEntry; pageEntries = new Map(); private _snapshotStorage: PersistentSnapshotStorage | undefined; private _entries = new Map(); private _version: number | undefined; constructor() { this.contextEntry = { startTime: Number.MAX_VALUE, endTime: Number.MIN_VALUE, browserName: '', options: { }, pages: [], resources: [], }; } async load(traceURL: string) { const response = await fetch(traceURL, { mode: 'cors' }); const blob = await response.blob(); const zipReader = new zipjs.ZipReader(new zipjs.BlobReader(blob), { useWebWorkers: false }) as zip.ZipReader; let traceEntry: zip.Entry | undefined; let networkEntry: zip.Entry | undefined; for (const entry of await zipReader.getEntries()) { if (entry.filename.endsWith('.trace')) traceEntry = entry; if (entry.filename.endsWith('.network')) networkEntry = entry; this._entries.set(entry.filename, entry); } this._snapshotStorage = new PersistentSnapshotStorage(this._entries); const traceWriter = new zipjs.TextWriter() as zip.TextWriter; await traceEntry!.getData!(traceWriter); for (const line of (await traceWriter.getData()).split('\n')) this.appendEvent(line); if (networkEntry) { const networkWriter = new zipjs.TextWriter(); await networkEntry.getData!(networkWriter); for (const line of (await networkWriter.getData()).split('\n')) this.appendEvent(line); } this._build(); } async resourceForSha1(sha1: string): Promise { const entry = this._entries.get('resources/' + sha1); if (!entry) return; const blobWriter = new zipjs.BlobWriter() as zip.BlobWriter; await entry!.getData!(blobWriter); return await blobWriter.getData(); } storage(): PersistentSnapshotStorage { return this._snapshotStorage!; } private _build() { for (const page of this.contextEntry!.pages) page.actions.sort((a1, a2) => a1.metadata.startTime - a2.metadata.startTime); this.contextEntry!.resources = this._snapshotStorage!.resources(); } private _pageEntry(pageId: string): PageEntry { let pageEntry = this.pageEntries.get(pageId); if (!pageEntry) { pageEntry = { actions: [], events: [], objects: {}, screencastFrames: [], }; this.pageEntries.set(pageId, pageEntry); this.contextEntry.pages.push(pageEntry); } return pageEntry; } appendEvent(line: string) { if (!line) return; const event = this._modernize(JSON.parse(line)); switch (event.type) { case 'context-options': { this.contextEntry.browserName = event.browserName; this.contextEntry.options = event.options; break; } case 'screencast-frame': { this._pageEntry(event.pageId).screencastFrames.push(event); break; } case 'action': { const metadata = event.metadata; const include = event.hasSnapshot; if (include && metadata.pageId) this._pageEntry(metadata.pageId).actions.push(event); break; } case 'event': { const metadata = event.metadata; if (metadata.pageId) { if (metadata.method === '__create__') this._pageEntry(metadata.pageId).objects[metadata.params.guid] = metadata.params.initializer; else this._pageEntry(metadata.pageId).events.push(event); } break; } case 'resource-snapshot': this._snapshotStorage!.addResource(event.snapshot); break; case 'frame-snapshot': this._snapshotStorage!.addFrameSnapshot(event.snapshot); break; } if (event.type === 'action' || event.type === 'event') { this.contextEntry!.startTime = Math.min(this.contextEntry!.startTime, event.metadata.startTime); this.contextEntry!.endTime = Math.max(this.contextEntry!.endTime, event.metadata.endTime); } } private _modernize(event: any): trace.TraceEvent { if (this._version === undefined) return event; for (let version = this._version; version < trace.VERSION; ++version) event = (this as any)[`_modernize_${version}_to_${version + 1}`].call(this, event); return event; } _modernize_0_to_1(event: any): any { if (event.type === 'action') { if (typeof event.metadata.error === 'string') event.metadata.error = { error: { name: 'Error', message: event.metadata.error } }; if (event.metadata && typeof event.hasSnapshot !== 'boolean') event.hasSnapshot = commandsWithTracingSnapshots.has(event.metadata); } return event; } _modernize_1_to_2(event: any): any { if (event.type === 'frame-snapshot' && event.snapshot.isMainFrame) { // Old versions had completely wrong viewport. event.snapshot.viewport = this.contextEntry.options.viewport || { width: 1280, height: 720 }; } return event; } _modernize_2_to_3(event: any): any { if (event.type === 'resource-snapshot' && !event.snapshot.request) { // Migrate from old ResourceSnapshot to new har entry format. const resource = event.snapshot; event.snapshot = { _frameref: resource.frameId, request: { url: resource.url, method: resource.method, headers: resource.requestHeaders, postData: resource.requestSha1 ? { _sha1: resource.requestSha1 } : undefined, }, response: { status: resource.status, headers: resource.responseHeaders, content: { mimeType: resource.contentType, _sha1: resource.responseSha1, }, }, _monotonicTime: resource.timestamp, }; } return event; } } export type ContextEntry = { startTime: number; endTime: number; browserName: string; options: trace.BrowserContextEventOptions; pages: PageEntry[]; resources: ResourceSnapshot[]; }; export type PageEntry = { actions: trace.ActionTraceEvent[]; events: trace.ActionTraceEvent[]; objects: { [key: string]: any }; screencastFrames: { sha1: string, timestamp: number, width: number, height: number, }[]; }; export class PersistentSnapshotStorage extends BaseSnapshotStorage { private _entries: Map; constructor(entries: Map) { super(); this._entries = entries; } async resourceContent(sha1: string): Promise { const entry = this._entries.get('resources/' + sha1)!; const writer = new zipjs.BlobWriter(); await entry.getData!(writer); return writer.getData(); } } // Prior to version 2 we did not have a hasSnapshot bit on. export const commandsWithTracingSnapshots = new Set([ 'EventTarget.waitForEventInfo', 'BrowserContext.waitForEventInfo', 'Page.waitForEventInfo', 'WebSocket.waitForEventInfo', 'ElectronApplication.waitForEventInfo', 'AndroidDevice.waitForEventInfo', 'Page.goBack', 'Page.goForward', 'Page.reload', 'Page.setViewportSize', 'Page.keyboardDown', 'Page.keyboardUp', 'Page.keyboardInsertText', 'Page.keyboardType', 'Page.keyboardPress', 'Page.mouseMove', 'Page.mouseDown', 'Page.mouseUp', 'Page.mouseClick', 'Page.mouseWheel', 'Page.touchscreenTap', 'Frame.evalOnSelector', 'Frame.evalOnSelectorAll', 'Frame.addScriptTag', 'Frame.addStyleTag', 'Frame.check', 'Frame.click', 'Frame.dragAndDrop', 'Frame.dblclick', 'Frame.dispatchEvent', 'Frame.evaluateExpression', 'Frame.evaluateExpressionHandle', 'Frame.fill', 'Frame.focus', 'Frame.getAttribute', 'Frame.goto', 'Frame.hover', 'Frame.innerHTML', 'Frame.innerText', 'Frame.inputValue', 'Frame.isChecked', 'Frame.isDisabled', 'Frame.isEnabled', 'Frame.isHidden', 'Frame.isVisible', 'Frame.isEditable', 'Frame.press', 'Frame.selectOption', 'Frame.setContent', 'Frame.setInputFiles', 'Frame.tap', 'Frame.textContent', 'Frame.type', 'Frame.uncheck', 'Frame.waitForTimeout', 'Frame.waitForFunction', 'Frame.waitForSelector', 'Frame.expect', 'JSHandle.evaluateExpression', 'ElementHandle.evaluateExpression', 'JSHandle.evaluateExpressionHandle', 'ElementHandle.evaluateExpressionHandle', 'ElementHandle.evalOnSelector', 'ElementHandle.evalOnSelectorAll', 'ElementHandle.check', 'ElementHandle.click', 'ElementHandle.dblclick', 'ElementHandle.dispatchEvent', 'ElementHandle.fill', 'ElementHandle.hover', 'ElementHandle.innerHTML', 'ElementHandle.innerText', 'ElementHandle.inputValue', 'ElementHandle.isChecked', 'ElementHandle.isDisabled', 'ElementHandle.isEditable', 'ElementHandle.isEnabled', 'ElementHandle.isHidden', 'ElementHandle.isVisible', 'ElementHandle.press', 'ElementHandle.scrollIntoViewIfNeeded', 'ElementHandle.selectOption', 'ElementHandle.selectText', 'ElementHandle.setInputFiles', 'ElementHandle.tap', 'ElementHandle.textContent', 'ElementHandle.type', 'ElementHandle.uncheck', 'ElementHandle.waitForElementState', 'ElementHandle.waitForSelector' ]);