chore(snapshot): brush up, start adding tests (#5646)
This commit is contained in:
parent
ee69de7726
commit
b253ee80c0
|
|
@ -195,9 +195,12 @@ export class DispatcherConnection {
|
|||
return;
|
||||
}
|
||||
|
||||
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
|
||||
const callMetadata: CallMetadata = {
|
||||
id,
|
||||
...validMetadata,
|
||||
pageId: sdkObject?.attribution.page?.uniqueId,
|
||||
frameId: sdkObject?.attribution.frame?.uniqueId,
|
||||
startTime: monotonicTime(),
|
||||
endTime: 0,
|
||||
type: dispatcher._type,
|
||||
|
|
@ -206,7 +209,6 @@ export class DispatcherConnection {
|
|||
log: [],
|
||||
};
|
||||
|
||||
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
|
||||
try {
|
||||
if (sdkObject)
|
||||
await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata);
|
||||
|
|
|
|||
|
|
@ -411,11 +411,11 @@ export class Frame extends SdkObject {
|
|||
private _setContentCounter = 0;
|
||||
readonly _detachedPromise: Promise<void>;
|
||||
private _detachedCallback = () => {};
|
||||
readonly idInSnapshot: string;
|
||||
readonly uniqueId: string;
|
||||
|
||||
constructor(page: Page, id: string, parentFrame: Frame | null) {
|
||||
super(page);
|
||||
this.idInSnapshot = parentFrame ? `frame@${id}` : page.idInSnapshot;
|
||||
this.uniqueId = parentFrame ? `frame@${page.uniqueId}/${id}` : page.uniqueId;
|
||||
this.attribution.frame = this;
|
||||
this._id = id;
|
||||
this._page = page;
|
||||
|
|
@ -585,6 +585,10 @@ export class Frame extends SdkObject {
|
|||
return this._context('main');
|
||||
}
|
||||
|
||||
_existingMainContext(): dom.FrameExecutionContext | null {
|
||||
return this._contextData.get('main')?.context || null;
|
||||
}
|
||||
|
||||
_utilityContext(): Promise<dom.FrameExecutionContext> {
|
||||
return this._context('utility');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export type CallMetadata = {
|
|||
log: string[];
|
||||
error?: string;
|
||||
point?: Point;
|
||||
pageId?: string;
|
||||
frameId?: string;
|
||||
};
|
||||
|
||||
export class SdkObject extends EventEmitter {
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ export class ExecutionContext extends SdkObject {
|
|||
return this._delegate.createHandle(this, remoteObject);
|
||||
}
|
||||
|
||||
async rawEvaluate(expression: string): Promise<void> {
|
||||
// Make sure to never return a value.
|
||||
await this._delegate.rawEvaluate(expression + '; 0');
|
||||
}
|
||||
|
||||
async doSlowMo() {
|
||||
// overrided in FrameExecutionContext
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,11 +147,11 @@ export class Page extends SdkObject {
|
|||
_ownedContext: BrowserContext | undefined;
|
||||
readonly selectors: Selectors;
|
||||
_video: Video | null = null;
|
||||
readonly idInSnapshot: string;
|
||||
readonly uniqueId: string;
|
||||
|
||||
constructor(delegate: PageDelegate, browserContext: BrowserContext) {
|
||||
super(browserContext);
|
||||
this.idInSnapshot = 'page@' + createGuid();
|
||||
this.uniqueId = 'page@' + createGuid();
|
||||
this.attribution.page = this;
|
||||
this._delegate = delegate;
|
||||
this._closedCallback = () => {};
|
||||
|
|
|
|||
|
|
@ -14,15 +14,19 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { HttpServer } from '../../utils/httpServer';
|
||||
import { BrowserContext } from '../browserContext';
|
||||
import { helper } from '../helper';
|
||||
import { Page } from '../page';
|
||||
import { ContextResources, FrameSnapshot } from './snapshot';
|
||||
import { SnapshotRenderer } from './snapshotRenderer';
|
||||
import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer';
|
||||
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter';
|
||||
|
||||
export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate {
|
||||
const kSnapshotInterval = 25;
|
||||
|
||||
export class InMemorySnapshotter extends EventEmitter implements SnapshotStorage, SnapshotterDelegate {
|
||||
private _blobs = new Map<string, Buffer>();
|
||||
private _resources = new Map<string, SnapshotterResource>();
|
||||
private _frameSnapshots = new Map<string, FrameSnapshot[]>();
|
||||
|
|
@ -32,23 +36,43 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
|
|||
private _snapshotter: Snapshotter;
|
||||
|
||||
constructor(context: BrowserContext) {
|
||||
super();
|
||||
this._server = new HttpServer();
|
||||
new SnapshotServer(this._server, this);
|
||||
this._snapshotter = new Snapshotter(context, this);
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
await this._snapshotter.start();
|
||||
async initialize(): Promise<string> {
|
||||
await this._snapshotter.initialize();
|
||||
return await this._server.start();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._snapshotter.dispose();
|
||||
this._server.stop().catch(() => {});
|
||||
async start(): Promise<void> {
|
||||
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
|
||||
}
|
||||
|
||||
async forceSnapshot(page: Page, snapshotId: string) {
|
||||
await this._snapshotter.forceSnapshot(page, snapshotId);
|
||||
async dispose() {
|
||||
this._snapshotter.dispose();
|
||||
await this._server.stop();
|
||||
}
|
||||
|
||||
async captureSnapshot(page: Page, snapshotId: string): Promise<SnapshotRenderer> {
|
||||
if (this._snapshots.has(snapshotId))
|
||||
throw new Error('Duplicate snapshotId: ' + snapshotId);
|
||||
|
||||
this._snapshotter.captureSnapshot(page, snapshotId);
|
||||
return new Promise<SnapshotRenderer>(fulfill => {
|
||||
const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => {
|
||||
if (renderer.snapshotId === snapshotId) {
|
||||
helper.removeEventListeners([listener]);
|
||||
fulfill(renderer);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async setAutoSnapshotInterval(interval: number): Promise<void> {
|
||||
await this._snapshotter.setAutoSnapshotInterval(interval);
|
||||
}
|
||||
|
||||
onBlob(blob: SnapshotterBlob): void {
|
||||
|
|
@ -66,14 +90,15 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
|
|||
}
|
||||
|
||||
onFrameSnapshot(snapshot: FrameSnapshot): void {
|
||||
const key = snapshot.pageId + '/' + snapshot.frameId;
|
||||
let frameSnapshots = this._frameSnapshots.get(key);
|
||||
let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
|
||||
if (!frameSnapshots) {
|
||||
frameSnapshots = [];
|
||||
this._frameSnapshots.set(key, frameSnapshots);
|
||||
this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
|
||||
}
|
||||
frameSnapshots.push(snapshot);
|
||||
this._snapshots.set(snapshot.snapshotId, new SnapshotRenderer(new Map(this._contextResources), frameSnapshots, frameSnapshots.length - 1));
|
||||
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 {
|
||||
|
|
@ -87,4 +112,8 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
|
|||
snapshotById(snapshotId: string): SnapshotRenderer | undefined {
|
||||
return this._snapshots.get(snapshotId);
|
||||
}
|
||||
|
||||
frameSnapshots(frameId: string): FrameSnapshot[] {
|
||||
return this._frameSnapshots.get(frameId) || [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export type FrameSnapshot = {
|
|||
pageId: string,
|
||||
frameId: string,
|
||||
frameUrl: string,
|
||||
pageTimestamp: number,
|
||||
collectionTime: number,
|
||||
doctype?: string,
|
||||
html: NodeSnapshot,
|
||||
resourceOverrides: ResourceOverride[],
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ export class SnapshotRenderer {
|
|||
private _snapshots: FrameSnapshot[];
|
||||
private _index: number;
|
||||
private _contextResources: ContextResources;
|
||||
readonly snapshotId: string;
|
||||
|
||||
constructor(contextResources: ContextResources, snapshots: FrameSnapshot[], index: number) {
|
||||
this._contextResources = contextResources;
|
||||
this._snapshots = snapshots;
|
||||
this._index = index;
|
||||
this.snapshotId = snapshots[index].snapshotId;
|
||||
}
|
||||
|
||||
render(): RenderedFrameSnapshot {
|
||||
|
|
|
|||
|
|
@ -53,25 +53,30 @@ export class Snapshotter {
|
|||
private _context: BrowserContext;
|
||||
private _delegate: SnapshotterDelegate;
|
||||
private _eventListeners: RegisteredListener[] = [];
|
||||
private _interval = 0;
|
||||
|
||||
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
|
||||
this._context = context;
|
||||
this._delegate = delegate;
|
||||
}
|
||||
|
||||
async start() {
|
||||
for (const page of context.pages())
|
||||
this._onPage(page);
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
|
||||
];
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
|
||||
const snapshot: FrameSnapshot = {
|
||||
snapshotId: data.snapshotId,
|
||||
pageId: source.page.idInSnapshot,
|
||||
frameId: source.frame.idInSnapshot,
|
||||
pageId: source.page.uniqueId,
|
||||
frameId: source.frame.uniqueId,
|
||||
frameUrl: data.url,
|
||||
doctype: data.doctype,
|
||||
html: data.html,
|
||||
viewport: data.viewport,
|
||||
pageTimestamp: data.timestamp,
|
||||
collectionTime: data.collectionTime,
|
||||
resourceOverrides: [],
|
||||
};
|
||||
for (const { url, content } of data.resourceOverrides) {
|
||||
|
|
@ -88,49 +93,57 @@ export class Snapshotter {
|
|||
});
|
||||
const initScript = '(' + frameSnapshotStreamer.toString() + ')()';
|
||||
await this._context._doAddInitScript(initScript);
|
||||
const frames = [];
|
||||
for (const page of this._context.pages())
|
||||
await page.mainFrame()._evaluateExpression(initScript, false, undefined, 'main');
|
||||
frames.push(...page.frames());
|
||||
frames.map(frame => {
|
||||
frame._existingMainContext()?.rawEvaluate(initScript).catch(debugExceptionHandler);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
}
|
||||
|
||||
async forceSnapshot(page: Page, snapshotId: string) {
|
||||
await Promise.all([
|
||||
page.frames().forEach(async frame => {
|
||||
try {
|
||||
const context = await frame._mainContext();
|
||||
await context.evaluateInternal(({ kSnapshotStreamer, snapshotId }) => {
|
||||
// Do not block action execution on the actual snapshot.
|
||||
Promise.resolve().then(() => (window as any)[kSnapshotStreamer].forceSnapshot(snapshotId));
|
||||
return undefined;
|
||||
}, { kSnapshotStreamer, snapshotId });
|
||||
} catch (e) {
|
||||
}
|
||||
})
|
||||
]);
|
||||
captureSnapshot(page: Page, snapshotId: string) {
|
||||
// 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 snapshotFrame = (frame: Frame) => {
|
||||
const context = frame._existingMainContext();
|
||||
context?.rawEvaluate(expression).catch(debugExceptionHandler);
|
||||
};
|
||||
page.frames().map(frame => snapshotFrame(frame));
|
||||
}
|
||||
|
||||
async setAutoSnapshotInterval(interval: number): Promise<void> {
|
||||
this._interval = interval;
|
||||
const frames = [];
|
||||
for (const page of this._context.pages())
|
||||
frames.push(...page.frames());
|
||||
await Promise.all(frames.map(frame => setIntervalInFrame(frame, interval)));
|
||||
}
|
||||
|
||||
private _onPage(page: Page) {
|
||||
const processNewFrame = (frame: Frame) => {
|
||||
annotateFrameHierarchy(frame);
|
||||
setIntervalInFrame(frame, this._interval);
|
||||
// FIXME: make addInitScript work for pages w/ setContent.
|
||||
const initScript = '(' + frameSnapshotStreamer.toString() + ')()';
|
||||
frame._existingMainContext()?.rawEvaluate(initScript).catch(debugExceptionHandler);
|
||||
};
|
||||
for (const frame of page.frames())
|
||||
processNewFrame(frame);
|
||||
this._eventListeners.push(helper.addEventListener(page, Page.Events.FrameAttached, processNewFrame));
|
||||
|
||||
// Push streamer interval on navigation.
|
||||
this._eventListeners.push(helper.addEventListener(page, Page.Events.InternalFrameNavigatedToNewDocument, frame => {
|
||||
setIntervalInFrame(frame, this._interval);
|
||||
}));
|
||||
|
||||
// Capture resources.
|
||||
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
|
||||
this._saveResource(page, response).catch(e => debugLogger.log('error', e));
|
||||
}));
|
||||
this._eventListeners.push(helper.addEventListener(page, Page.Events.FrameAttached, async (frame: Frame) => {
|
||||
try {
|
||||
const frameElement = await frame.frameElement();
|
||||
const parent = frame.parentFrame();
|
||||
if (!parent)
|
||||
return;
|
||||
const context = await parent._mainContext();
|
||||
await context.evaluateInternal(({ kSnapshotStreamer, frameElement, frameId }) => {
|
||||
(window as any)[kSnapshotStreamer].markIframe(frameElement, frameId);
|
||||
}, { kSnapshotStreamer, frameElement, frameId: frame.idInSnapshot });
|
||||
frameElement.dispose();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private async _saveResource(page: Page, response: network.Response) {
|
||||
|
|
@ -158,8 +171,8 @@ export class Snapshotter {
|
|||
const body = await response.body().catch(e => debugLogger.log('error', e));
|
||||
const responseSha1 = body ? calculateSha1(body) : 'none';
|
||||
const resource: SnapshotterResource = {
|
||||
pageId: page.idInSnapshot,
|
||||
frameId: response.frame().idInSnapshot,
|
||||
pageId: page.uniqueId,
|
||||
frameId: response.frame().uniqueId,
|
||||
resourceId: 'resource@' + createGuid(),
|
||||
url,
|
||||
contentType,
|
||||
|
|
@ -177,3 +190,29 @@ export class Snapshotter {
|
|||
this._delegate.onBlob({ sha1: responseSha1, buffer: body });
|
||||
}
|
||||
}
|
||||
|
||||
async function setIntervalInFrame(frame: Frame, interval: number) {
|
||||
const context = frame._existingMainContext();
|
||||
await context?.evaluateInternal(({ kSnapshotStreamer, interval }) => {
|
||||
(window as any)[kSnapshotStreamer].setSnapshotInterval(interval);
|
||||
}, { kSnapshotStreamer, interval }).catch(debugExceptionHandler);
|
||||
}
|
||||
|
||||
async function annotateFrameHierarchy(frame: Frame) {
|
||||
try {
|
||||
const frameElement = await frame.frameElement();
|
||||
const parent = frame.parentFrame();
|
||||
if (!parent)
|
||||
return;
|
||||
const context = await parent._mainContext();
|
||||
await context?.evaluateInternal(({ kSnapshotStreamer, frameElement, frameId }) => {
|
||||
(window as any)[kSnapshotStreamer].markIframe(frameElement, frameId);
|
||||
}, { kSnapshotStreamer, frameElement, frameId: frame.uniqueId });
|
||||
frameElement.dispose();
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
function debugExceptionHandler(e: Error) {
|
||||
// console.error(e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export type SnapshotData = {
|
|||
viewport: { width: number, height: number },
|
||||
url: string,
|
||||
snapshotId: string,
|
||||
timestamp: number,
|
||||
collectionTime: number,
|
||||
};
|
||||
|
||||
export const kSnapshotStreamer = '__playwright_snapshot_streamer_';
|
||||
|
|
@ -80,6 +82,7 @@ export function frameSnapshotStreamer() {
|
|||
private _readingStyleSheet = false; // To avoid invalidating due to our own reads.
|
||||
private _fakeBase: HTMLBaseElement;
|
||||
private _observer: MutationObserver;
|
||||
private _interval = 0;
|
||||
|
||||
constructor() {
|
||||
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'insertRule', (sheet: CSSStyleSheet) => this._invalidateStyleSheet(sheet));
|
||||
|
|
@ -94,8 +97,6 @@ export function frameSnapshotStreamer() {
|
|||
this._observer = new MutationObserver(list => this._handleMutations(list));
|
||||
const observerConfig = { attributes: true, subtree: true };
|
||||
this._observer.observe(document, observerConfig);
|
||||
|
||||
this._streamSnapshot('snapshot@initial');
|
||||
}
|
||||
|
||||
private _interceptNativeMethod(obj: any, method: string, cb: (thisObj: any, result: any) => void) {
|
||||
|
|
@ -167,21 +168,29 @@ export function frameSnapshotStreamer() {
|
|||
(iframeElement as any)[kSnapshotFrameId] = frameId;
|
||||
}
|
||||
|
||||
forceSnapshot(snapshotId: string) {
|
||||
this._streamSnapshot(snapshotId);
|
||||
captureSnapshot(snapshotId: string) {
|
||||
this._streamSnapshot(snapshotId, true);
|
||||
}
|
||||
|
||||
private _streamSnapshot(snapshotId: string) {
|
||||
setSnapshotInterval(interval: number) {
|
||||
this._interval = interval;
|
||||
if (interval)
|
||||
this._streamSnapshot(`snapshot@${performance.now()}`, false);
|
||||
}
|
||||
|
||||
private _streamSnapshot(snapshotId: string, explicitRequest: boolean) {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = undefined;
|
||||
}
|
||||
try {
|
||||
const snapshot = this._captureSnapshot(snapshotId);
|
||||
(window as any)[kSnapshotBinding](snapshot).catch((e: any) => {});
|
||||
const snapshot = this._captureSnapshot(snapshotId, explicitRequest);
|
||||
if (snapshot)
|
||||
(window as any)[kSnapshotBinding](snapshot);
|
||||
} catch (e) {
|
||||
}
|
||||
this._timer = setTimeout(() => this._streamSnapshot(`snapshot@${performance.now()}`), 100);
|
||||
if (this._interval)
|
||||
this._timer = setTimeout(() => this._streamSnapshot(`snapshot@${performance.now()}`, false), this._interval);
|
||||
}
|
||||
|
||||
private _sanitizeUrl(url: string): string {
|
||||
|
|
@ -231,7 +240,8 @@ export function frameSnapshotStreamer() {
|
|||
}
|
||||
}
|
||||
|
||||
private _captureSnapshot(snapshotId: string): SnapshotData {
|
||||
private _captureSnapshot(snapshotId: string, explicitRequest: boolean): SnapshotData | undefined {
|
||||
const timestamp = performance.now();
|
||||
const snapshotNumber = ++this._lastSnapshotNumber;
|
||||
let nodeCounter = 0;
|
||||
let shadowDomNesting = 0;
|
||||
|
|
@ -396,10 +406,14 @@ export function frameSnapshotStreamer() {
|
|||
};
|
||||
|
||||
let html: NodeSnapshot;
|
||||
if (document.documentElement)
|
||||
html = visitNode(document.documentElement)!.n;
|
||||
else
|
||||
let htmlEquals = false;
|
||||
if (document.documentElement) {
|
||||
const { equals, n } = visitNode(document.documentElement)!;
|
||||
htmlEquals = equals;
|
||||
html = n;
|
||||
} else {
|
||||
html = ['html'];
|
||||
}
|
||||
|
||||
const result: SnapshotData = {
|
||||
html,
|
||||
|
|
@ -411,19 +425,27 @@ export function frameSnapshotStreamer() {
|
|||
},
|
||||
url: location.href,
|
||||
snapshotId,
|
||||
timestamp,
|
||||
collectionTime: 0,
|
||||
};
|
||||
|
||||
let allOverridesAreRefs = true;
|
||||
for (const sheet of this._allStyleSheetsWithUrlOverride) {
|
||||
const content = this._updateLinkStyleSheetTextIfNeeded(sheet, snapshotNumber);
|
||||
if (content === undefined) {
|
||||
// Unable to capture stylsheet contents.
|
||||
continue;
|
||||
}
|
||||
if (typeof content !== 'number')
|
||||
allOverridesAreRefs = false;
|
||||
const base = this._getSheetBase(sheet);
|
||||
const url = removeHash(this._resolveUrl(base, sheet.href!));
|
||||
result.resourceOverrides.push({ url, content });
|
||||
}
|
||||
|
||||
result.collectionTime = performance.now() - result.timestamp;
|
||||
if (!explicitRequest && htmlEquals && allOverridesAreRefs)
|
||||
return undefined;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export class RecorderSupplement {
|
|||
const recorderApp = await RecorderApp.open(this._context);
|
||||
this._recorderApp = recorderApp;
|
||||
recorderApp.once('close', () => {
|
||||
this._snapshotter.stop();
|
||||
this._snapshotter.dispose().catch(() => {});
|
||||
this._recorderApp = null;
|
||||
});
|
||||
recorderApp.on('event', (data: EventData) => {
|
||||
|
|
@ -235,7 +235,7 @@ export class RecorderSupplement {
|
|||
this._resume(false).catch(() => {});
|
||||
});
|
||||
|
||||
const snapshotBaseUrl = await this._snapshotter.start() + '/snapshot/';
|
||||
const snapshotBaseUrl = await this._snapshotter.initialize() + '/snapshot/';
|
||||
await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest(), snapshotBaseUrl });
|
||||
await this._context.extendInjectedScript(consoleApiSource.source);
|
||||
(this._context as any).recorderAppForTest = recorderApp;
|
||||
|
|
@ -399,18 +399,18 @@ export class RecorderSupplement {
|
|||
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) });
|
||||
}
|
||||
|
||||
async _captureSnapshot(sdkObject: SdkObject, metadata: CallMetadata, phase: 'before' | 'after' | 'in') {
|
||||
_captureSnapshot(sdkObject: SdkObject, metadata: CallMetadata, phase: 'before' | 'after' | 'in') {
|
||||
if (sdkObject.attribution.page) {
|
||||
const snapshotId = `${phase}@${metadata.id}`;
|
||||
this._snapshots.add(snapshotId);
|
||||
await this._snapshotter.forceSnapshot(sdkObject.attribution.page, snapshotId);
|
||||
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotId);
|
||||
}
|
||||
}
|
||||
|
||||
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||
if (this._mode === 'recording')
|
||||
return;
|
||||
await this._captureSnapshot(sdkObject, metadata, 'before');
|
||||
this._captureSnapshot(sdkObject, metadata, 'before');
|
||||
this._currentCallsMetadata.set(metadata, sdkObject);
|
||||
this._allMetadatas.set(metadata.id, metadata);
|
||||
this._updateUserSources();
|
||||
|
|
|
|||
|
|
@ -112,7 +112,8 @@ class ContextTracer implements SnapshotterDelegate {
|
|||
}
|
||||
|
||||
async start() {
|
||||
await this._snapshotter.start();
|
||||
await this._snapshotter.initialize();
|
||||
await this._snapshotter.setAutoSnapshotInterval(100);
|
||||
}
|
||||
|
||||
onBlob(blob: SnapshotterBlob): void {
|
||||
|
|
@ -156,7 +157,7 @@ class ContextTracer implements SnapshotterDelegate {
|
|||
return;
|
||||
const snapshotId = createGuid();
|
||||
snapshotsForMetadata(metadata).push({ name, snapshotId });
|
||||
await this._snapshotter.forceSnapshot(sdkObject.attribution.page, snapshotId);
|
||||
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotId);
|
||||
}
|
||||
|
||||
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||
|
|
@ -166,7 +167,7 @@ class ContextTracer implements SnapshotterDelegate {
|
|||
timestamp: monotonicTime(),
|
||||
type: 'action',
|
||||
contextId: this._contextId,
|
||||
pageId: sdkObject.attribution.page.idInSnapshot,
|
||||
pageId: sdkObject.attribution.page.uniqueId,
|
||||
objectType: metadata.type,
|
||||
method: metadata.method,
|
||||
// FIXME: filter out evaluation snippets, binary
|
||||
|
|
@ -182,7 +183,7 @@ class ContextTracer implements SnapshotterDelegate {
|
|||
}
|
||||
|
||||
private _onPage(page: Page) {
|
||||
const pageId = page.idInSnapshot;
|
||||
const pageId = page.uniqueId;
|
||||
|
||||
const event: trace.PageCreatedTraceEvent = {
|
||||
timestamp: monotonicTime(),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { msToString } from '../uiUtils';
|
|||
|
||||
export interface CallLogProps {
|
||||
log: CallLog[],
|
||||
onHover: (callLogId: number | undefined, phase?: 'before' | 'after' | 'in') => void
|
||||
onHover: (callLog: CallLog | undefined, phase?: 'before' | 'after' | 'in') => void
|
||||
}
|
||||
|
||||
export const CallLogView: React.FC<CallLogProps> = ({
|
||||
|
|
@ -52,9 +52,9 @@ export const CallLogView: React.FC<CallLogProps> = ({
|
|||
<span className={'codicon ' + iconClass(callLog)}></span>
|
||||
{ typeof callLog.duration === 'number' ? <span className='call-log-time'>— {msToString(callLog.duration)}</span> : undefined}
|
||||
{ <div style={{flex: 'auto'}}></div> }
|
||||
<span className={'codicon codicon-vm-outline preview' + (callLog.snapshots.before ? '' : ' invisible')} onMouseEnter={() => onHover(callLog.id, 'before')} onMouseLeave={() => onHover(undefined)}></span>
|
||||
<span className={'codicon codicon-vm-running preview' + (callLog.snapshots.in ? '' : ' invisible')} onMouseEnter={() => onHover(callLog.id, 'in')} onMouseLeave={() => onHover(undefined)}></span>
|
||||
<span className={'codicon codicon-vm-active preview' + (callLog.snapshots.after ? '' : ' invisible')} onMouseEnter={() => onHover(callLog.id, 'after')} onMouseLeave={() => onHover(undefined)}></span>
|
||||
<span className={'codicon codicon-vm-outline preview' + (callLog.snapshots.before ? '' : ' invisible')} onMouseEnter={() => onHover(callLog, 'before')} onMouseLeave={() => onHover(undefined)}></span>
|
||||
<span className={'codicon codicon-vm-running preview' + (callLog.snapshots.in ? '' : ' invisible')} onMouseEnter={() => onHover(callLog, 'in')} onMouseLeave={() => onHover(undefined)}></span>
|
||||
<span className={'codicon codicon-vm-active preview' + (callLog.snapshots.after ? '' : ' invisible')} onMouseEnter={() => onHover(callLog, 'after')} onMouseLeave={() => onHover(undefined)}></span>
|
||||
</div>
|
||||
{ (isExpanded ? callLog.messages : []).map((message, i) => {
|
||||
return <div className='call-log-message' key={i}>
|
||||
|
|
|
|||
|
|
@ -81,19 +81,19 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
return <div className='recorder'>
|
||||
<Toolbar>
|
||||
<ToolbarButton icon='record' title='Record' toggled={mode == 'recording'} onClick={() => {
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { });
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }});
|
||||
}}>Record</ToolbarButton>
|
||||
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
|
||||
copy(source.text);
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-continue' title='Resume' disabled={!paused} onClick={() => {
|
||||
window.dispatch({ event: 'resume' }).catch(() => {});
|
||||
window.dispatch({ event: 'resume' });
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-pause' title='Pause' disabled={paused} onClick={() => {
|
||||
window.dispatch({ event: 'pause' }).catch(() => {});
|
||||
window.dispatch({ event: 'pause' });
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-step-over' title='Step over' disabled={!paused} onClick={() => {
|
||||
window.dispatch({ event: 'step' }).catch(() => {});
|
||||
window.dispatch({ event: 'step' });
|
||||
}}></ToolbarButton>
|
||||
<select className='recorder-chooser' hidden={!sources.length} value={file} onChange={event => {
|
||||
setFile(event.target.selectedOptions[0].value);
|
||||
|
|
@ -106,7 +106,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
</select>
|
||||
<div style={{flex: 'auto'}}></div>
|
||||
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
||||
window.dispatch({ event: 'clear' }).catch(() => {});
|
||||
window.dispatch({ event: 'clear' });
|
||||
}}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<SplitView sidebarSize={200} sidebarHidden={mode === 'recording'}>
|
||||
|
|
@ -121,8 +121,8 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
|
||||
}} />
|
||||
</Toolbar>
|
||||
<CallLogView log={Array.from(log.values())} onHover={(callLogId, phase) => {
|
||||
window.dispatch({ event: 'callLogHovered', params: { callLogId, phase } }).catch(() => {});
|
||||
<CallLogView log={Array.from(log.values())} onHover={(callLog, phase) => {
|
||||
window.dispatch({ event: 'callLogHovered', params: { callLogId: callLog?.id, phase } });
|
||||
}}/>
|
||||
</div>
|
||||
</SplitView>
|
||||
|
|
|
|||
139
test/snapshotter.spec.ts
Normal file
139
test/snapshotter.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* 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 { folio as baseFolio } from './fixtures';
|
||||
import { InMemorySnapshotter } from '../lib/server/snapshot/inMemorySnapshotter';
|
||||
|
||||
type TestFixtures = {
|
||||
snapshotter: any;
|
||||
};
|
||||
|
||||
export const fixtures = baseFolio.extend<TestFixtures>();
|
||||
fixtures.snapshotter.init(async ({ context, toImpl }, runTest) => {
|
||||
const snapshotter = new InMemorySnapshotter(toImpl(context));
|
||||
await snapshotter.initialize();
|
||||
await runTest(snapshotter);
|
||||
await snapshotter.dispose();
|
||||
});
|
||||
|
||||
const { it, describe, expect } = fixtures.build();
|
||||
|
||||
describe('snapshots', (suite, { mode }) => {
|
||||
suite.skip(mode !== 'default');
|
||||
}, () => {
|
||||
|
||||
it('should collect snapshot', async ({ snapshotter, page, toImpl }) => {
|
||||
await page.setContent('<button>Hello</button>');
|
||||
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
|
||||
expect(distillSnapshot(snapshot)).toBe('<BUTTON>Hello</BUTTON>');
|
||||
});
|
||||
|
||||
it('should capture resources', async ({ snapshotter, page, toImpl, server }) => {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.route('**/style.css', route => {
|
||||
route.fulfill({ body: 'button { color: red; }', }).catch(() => {});
|
||||
});
|
||||
await page.setContent('<link rel="stylesheet" href="style.css"><button>Hello</button>');
|
||||
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
|
||||
const { resources } = snapshot.render();
|
||||
const cssHref = `http://localhost:${server.PORT}/style.css`;
|
||||
expect(resources[cssHref]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should collect multiple', async ({ snapshotter, page, toImpl }) => {
|
||||
await page.setContent('<button>Hello</button>');
|
||||
const snapshots = [];
|
||||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
|
||||
await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
|
||||
expect(snapshots.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should only collect on change', async ({ snapshotter, page }) => {
|
||||
await page.setContent('<button>Hello</button>');
|
||||
const snapshots = [];
|
||||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
snapshotter.setAutoSnapshotInterval(25),
|
||||
]);
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
page.setContent('<button>Hello 2</button>')
|
||||
]);
|
||||
expect(snapshots.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should respect inline CSSOM change', async ({ snapshotter, page }) => {
|
||||
await page.setContent('<style>button { color: red; }</style><button>Hello</button>');
|
||||
const snapshots = [];
|
||||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
snapshotter.setAutoSnapshotInterval(25),
|
||||
]);
|
||||
expect(distillSnapshot(snapshots[0])).toBe('<style>button { color: red; }</style><BUTTON>Hello</BUTTON>');
|
||||
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
page.evaluate(() => {
|
||||
(document.styleSheets[0].cssRules[0] as any).style.color = 'blue';
|
||||
})
|
||||
]);
|
||||
expect(distillSnapshot(snapshots[1])).toBe('<style>button { color: blue; }</style><BUTTON>Hello</BUTTON>');
|
||||
});
|
||||
|
||||
it('should respect subresource CSSOM change', async ({ snapshotter, page, server }) => {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.route('**/style.css', route => {
|
||||
route.fulfill({ body: 'button { color: red; }', }).catch(() => {});
|
||||
});
|
||||
await page.setContent('<link rel="stylesheet" href="style.css"><button>Hello</button>');
|
||||
|
||||
const snapshots = [];
|
||||
snapshotter.on('snapshot', snapshot => snapshots.push(snapshot));
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
snapshotter.setAutoSnapshotInterval(25),
|
||||
]);
|
||||
expect(distillSnapshot(snapshots[0])).toBe('<LINK rel=\"stylesheet\" href=\"style.css\"><BUTTON>Hello</BUTTON>');
|
||||
|
||||
await Promise.all([
|
||||
new Promise(f => snapshotter.once('snapshot', f)),
|
||||
page.evaluate(() => {
|
||||
(document.styleSheets[0].cssRules[0] as any).style.color = 'blue';
|
||||
})
|
||||
]);
|
||||
const { resources } = snapshots[1].render();
|
||||
const cssHref = `http://localhost:${server.PORT}/style.css`;
|
||||
const { sha1 } = resources[cssHref];
|
||||
expect(snapshotter.resourceContent(sha1).toString()).toBe('button { color: blue; }');
|
||||
});
|
||||
});
|
||||
|
||||
function distillSnapshot(snapshot) {
|
||||
const { html } = snapshot.render();
|
||||
return html
|
||||
.replace(/<script>function snapshotScript[.\s\S]*/, '')
|
||||
.replace(/<BASE href="about:blank">/, '')
|
||||
.replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '')
|
||||
.replace(/<HTML>/, '')
|
||||
.replace(/<\/HTML>/, '')
|
||||
.replace(/<HEAD>/, '')
|
||||
.replace(/<\/HEAD>/, '')
|
||||
.replace(/<BODY>/, '')
|
||||
.replace(/<\/BODY>/, '');
|
||||
}
|
||||
Loading…
Reference in a new issue