playwright/src/server/snapshot/snapshotter.ts

166 lines
5.7 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 { 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 } from './snapshotTypes';
import { ElementHandle } from '../dom';
import * as mime from 'mime';
export type SnapshotterBlob = {
buffer: Buffer,
sha1: string,
};
export interface SnapshotterDelegate {
onSnapshotterBlob(blob: SnapshotterBlob): 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;
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();
}
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)),
];
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) + '.' + (mime.getExtension(contentType) || 'dat');
this._delegate.onSnapshotterBlob({ 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 _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) {
}
}
}