playwright/src/server/snapshot/snapshotter.ts
Dmitry Gozman b0a7843247
chore: migrate tracing ResourceSnapshot to follow har entry format (#8391)
This will ease the migration of tracing to har.
2021-08-24 13:17:58 -07:00

271 lines
9.1 KiB
TypeScript

/**
* 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 { BrowserContext } from '../browserContext';
import { Page } from '../page';
import * as network from '../network';
import { eventsHelper, RegisteredListener } from '../../utils/eventsHelper';
import { debugLogger } from '../../utils/debugLogger';
import { Frame } from '../frames';
import { frameSnapshotStreamer, SnapshotData } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { ElementHandle } from '../dom';
export type SnapshotterBlob = {
buffer: Buffer,
sha1: string,
};
export interface SnapshotterDelegate {
onBlob(blob: SnapshotterBlob): void;
onResourceSnapshot(resource: ResourceSnapshot): void;
onFrameSnapshot(snapshot: FrameSnapshot): void;
}
export class Snapshotter {
private _context: BrowserContext;
private _delegate: SnapshotterDelegate;
private _eventListeners: RegisteredListener[] = [];
private _snapshotStreamer: string;
private _initialized = false;
private _started = false;
private _fetchedResponses = new Map<network.Response, string>();
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
this._context = context;
this._delegate = delegate;
const guid = createGuid();
this._snapshotStreamer = '__playwright_snapshot_streamer_' + guid;
}
started(): boolean {
return this._started;
}
async start() {
this._started = true;
if (!this._initialized) {
this._initialized = true;
await this._initialize();
}
await this.reset();
// Replay resources loaded in all pages.
for (const page of this._context.pages()) {
for (const response of page._frameManager._responses)
this._saveResource(response).catch(e => debugLogger.log('error', e));
}
}
async reset() {
if (this._started)
await this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`);
}
async stop() {
this._started = false;
}
async _initialize() {
for (const page of this._context.pages())
this._onPage(page);
this._eventListeners = [
eventsHelper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
eventsHelper.addEventListener(this._context, BrowserContext.Events.Response, (response: network.Response) => {
this._saveResource(response).catch(e => debugLogger.log('error', e));
}),
];
const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}")`;
await this._context._doAddInitScript(initScript);
await this._runInAllFrames(initScript);
}
private async _runInAllFrames(expression: string) {
const frames = [];
for (const page of this._context.pages())
frames.push(...page.frames());
await Promise.all(frames.map(frame => {
return frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(e => debugLogger.log('error', e));
}));
}
dispose() {
eventsHelper.removeEventListeners(this._eventListeners);
}
async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise<void> {
// Prepare expression synchronously.
const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`;
// In a best-effort manner, without waiting for it, mark target element.
element?.callFunctionNoReply((element: Element, snapshotName: string) => {
element.setAttribute('__playwright_target__', snapshotName);
}, snapshotName);
// In each frame, in a non-stalling manner, capture the snapshots.
const snapshots = page.frames().map(async frame => {
const data = await frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(e => debugLogger.log('error', e)) as SnapshotData;
// Something went wrong -> bail out, our snapshots are best-efforty.
if (!data || !this._started)
return;
const snapshot: FrameSnapshot = {
snapshotName,
pageId: page.guid,
frameId: frame.guid,
frameUrl: data.url,
doctype: data.doctype,
html: data.html,
viewport: data.viewport,
timestamp: monotonicTime(),
collectionTime: data.collectionTime,
resourceOverrides: [],
isMainFrame: page.mainFrame() === frame
};
for (const { url, content, contentType } of data.resourceOverrides) {
if (typeof content === 'string') {
const buffer = Buffer.from(content);
const sha1 = calculateSha1(buffer) + mimeToExtension(contentType);
this._delegate.onBlob({ sha1, buffer });
snapshot.resourceOverrides.push({ url, sha1 });
} else {
snapshot.resourceOverrides.push({ url, ref: content });
}
}
this._delegate.onFrameSnapshot(snapshot);
});
await Promise.all(snapshots);
}
private _onPage(page: Page) {
// Annotate frame hierarchy so that snapshots could include frame ids.
for (const frame of page.frames())
this._annotateFrameHierarchy(frame);
this._eventListeners.push(eventsHelper.addEventListener(page, Page.Events.FrameAttached, frame => this._annotateFrameHierarchy(frame)));
}
private async _saveResource(response: network.Response) {
if (!this._started)
return;
const isRedirect = response.status() >= 300 && response.status() <= 399;
if (isRedirect)
return;
// We do not need scripts for snapshots.
if (response.request().resourceType() === 'script')
return;
// Shortcut all redirects - we cannot intercept them properly.
let original = response.request();
while (original.redirectedFrom())
original = original.redirectedFrom()!;
const url = original.url();
let contentType = '';
for (const { name, value } of response.headers()) {
if (name.toLowerCase() === 'content-type')
contentType = value;
}
const method = original.method();
const status = response.status();
const requestBody = original.postDataBuffer();
const requestSha1 = requestBody ? calculateSha1(requestBody) + mimeToExtension(contentType) : '';
if (requestBody)
this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody });
const requestHeaders = original.headers();
// Only fetch response bodies once.
let responseSha1 = this._fetchedResponses.get(response);
{
if (responseSha1 === undefined) {
const body = await response.body().catch(e => debugLogger.log('error', e));
// Bail out after each async hop.
if (!this._started)
return;
responseSha1 = body ? calculateSha1(body) + mimeToExtension(contentType) : '';
if (body)
this._delegate.onBlob({ sha1: responseSha1, buffer: body });
this._fetchedResponses.set(response, responseSha1);
}
}
const resource: ResourceSnapshot = {
_frameref: response.frame().guid,
request: {
url,
method,
headers: requestHeaders,
postData: requestSha1 ? { text: '', _sha1: requestSha1 } : undefined,
},
response: {
status,
headers: response.headers(),
content: {
mimeType: contentType,
_sha1: responseSha1,
},
},
_monotonicTime: monotonicTime()
};
this._delegate.onResourceSnapshot(resource);
}
private async _annotateFrameHierarchy(frame: Frame) {
try {
const frameElement = await frame.frameElement();
const parent = frame.parentFrame();
if (!parent)
return;
const context = await parent._mainContext();
await context?.evaluate(({ snapshotStreamer, frameElement, frameId }) => {
(window as any)[snapshotStreamer].markIframe(frameElement, frameId);
}, { snapshotStreamer: this._snapshotStreamer, frameElement, frameId: frame.guid });
frameElement.dispose();
} catch (e) {
}
}
}
const kMimeToExtension: { [key: string]: string } = {
'application/javascript': 'js',
'application/json': 'json',
'application/json5': 'json5',
'application/pdf': 'pdf',
'application/xhtml+xml': 'xhtml',
'application/zip': 'zip',
'font/otf': 'otf',
'font/woff': 'woff',
'font/woff2': 'woff2',
'image/bmp': 'bmp',
'image/gif': 'gif',
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/tiff': 'tiff',
'image/svg+xml': 'svg',
'text/css': 'css',
'text/csv': 'csv',
'text/html': 'html',
'text/plain': 'text',
'video/mp4': 'mp4',
'video/mpeg': 'mpeg',
};
function mimeToExtension(contentType: string): string {
return '.' + (kMimeToExtension[contentType] || 'dat');
}