chore(snapshot): brush up, start adding tests (#5646)

This commit is contained in:
Pavel Feldman 2021-03-01 12:20:04 -08:00 committed by GitHub
parent ee69de7726
commit b253ee80c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 333 additions and 86 deletions

View file

@ -195,9 +195,12 @@ export class DispatcherConnection {
return; return;
} }
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
const callMetadata: CallMetadata = { const callMetadata: CallMetadata = {
id, id,
...validMetadata, ...validMetadata,
pageId: sdkObject?.attribution.page?.uniqueId,
frameId: sdkObject?.attribution.frame?.uniqueId,
startTime: monotonicTime(), startTime: monotonicTime(),
endTime: 0, endTime: 0,
type: dispatcher._type, type: dispatcher._type,
@ -206,7 +209,6 @@ export class DispatcherConnection {
log: [], log: [],
}; };
const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined;
try { try {
if (sdkObject) if (sdkObject)
await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata); await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata);

View file

@ -411,11 +411,11 @@ export class Frame extends SdkObject {
private _setContentCounter = 0; private _setContentCounter = 0;
readonly _detachedPromise: Promise<void>; readonly _detachedPromise: Promise<void>;
private _detachedCallback = () => {}; private _detachedCallback = () => {};
readonly idInSnapshot: string; readonly uniqueId: string;
constructor(page: Page, id: string, parentFrame: Frame | null) { constructor(page: Page, id: string, parentFrame: Frame | null) {
super(page); super(page);
this.idInSnapshot = parentFrame ? `frame@${id}` : page.idInSnapshot; this.uniqueId = parentFrame ? `frame@${page.uniqueId}/${id}` : page.uniqueId;
this.attribution.frame = this; this.attribution.frame = this;
this._id = id; this._id = id;
this._page = page; this._page = page;
@ -585,6 +585,10 @@ export class Frame extends SdkObject {
return this._context('main'); return this._context('main');
} }
_existingMainContext(): dom.FrameExecutionContext | null {
return this._contextData.get('main')?.context || null;
}
_utilityContext(): Promise<dom.FrameExecutionContext> { _utilityContext(): Promise<dom.FrameExecutionContext> {
return this._context('utility'); return this._context('utility');
} }

View file

@ -44,6 +44,8 @@ export type CallMetadata = {
log: string[]; log: string[];
error?: string; error?: string;
point?: Point; point?: Point;
pageId?: string;
frameId?: string;
}; };
export class SdkObject extends EventEmitter { export class SdkObject extends EventEmitter {

View file

@ -79,6 +79,11 @@ export class ExecutionContext extends SdkObject {
return this._delegate.createHandle(this, remoteObject); 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() { async doSlowMo() {
// overrided in FrameExecutionContext // overrided in FrameExecutionContext
} }

View file

@ -147,11 +147,11 @@ export class Page extends SdkObject {
_ownedContext: BrowserContext | undefined; _ownedContext: BrowserContext | undefined;
readonly selectors: Selectors; readonly selectors: Selectors;
_video: Video | null = null; _video: Video | null = null;
readonly idInSnapshot: string; readonly uniqueId: string;
constructor(delegate: PageDelegate, browserContext: BrowserContext) { constructor(delegate: PageDelegate, browserContext: BrowserContext) {
super(browserContext); super(browserContext);
this.idInSnapshot = 'page@' + createGuid(); this.uniqueId = 'page@' + createGuid();
this.attribution.page = this; this.attribution.page = this;
this._delegate = delegate; this._delegate = delegate;
this._closedCallback = () => {}; this._closedCallback = () => {};

View file

@ -14,15 +14,19 @@
* limitations under the License. * limitations under the License.
*/ */
import { EventEmitter } from 'events';
import { HttpServer } from '../../utils/httpServer'; import { HttpServer } from '../../utils/httpServer';
import { BrowserContext } from '../browserContext'; import { BrowserContext } from '../browserContext';
import { helper } from '../helper';
import { Page } from '../page'; import { Page } from '../page';
import { ContextResources, FrameSnapshot } from './snapshot'; import { ContextResources, FrameSnapshot } from './snapshot';
import { SnapshotRenderer } from './snapshotRenderer'; import { SnapshotRenderer } from './snapshotRenderer';
import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer'; import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter'; 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 _blobs = new Map<string, Buffer>();
private _resources = new Map<string, SnapshotterResource>(); private _resources = new Map<string, SnapshotterResource>();
private _frameSnapshots = new Map<string, FrameSnapshot[]>(); private _frameSnapshots = new Map<string, FrameSnapshot[]>();
@ -32,23 +36,43 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
private _snapshotter: Snapshotter; private _snapshotter: Snapshotter;
constructor(context: BrowserContext) { constructor(context: BrowserContext) {
super();
this._server = new HttpServer(); this._server = new HttpServer();
new SnapshotServer(this._server, this); new SnapshotServer(this._server, this);
this._snapshotter = new Snapshotter(context, this); this._snapshotter = new Snapshotter(context, this);
} }
async start(): Promise<string> { async initialize(): Promise<string> {
await this._snapshotter.start(); await this._snapshotter.initialize();
return await this._server.start(); return await this._server.start();
} }
stop() { async start(): Promise<void> {
this._snapshotter.dispose(); await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
this._server.stop().catch(() => {});
} }
async forceSnapshot(page: Page, snapshotId: string) { async dispose() {
await this._snapshotter.forceSnapshot(page, snapshotId); 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 { onBlob(blob: SnapshotterBlob): void {
@ -66,14 +90,15 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
} }
onFrameSnapshot(snapshot: FrameSnapshot): void { onFrameSnapshot(snapshot: FrameSnapshot): void {
const key = snapshot.pageId + '/' + snapshot.frameId; let frameSnapshots = this._frameSnapshots.get(snapshot.frameId);
let frameSnapshots = this._frameSnapshots.get(key);
if (!frameSnapshots) { if (!frameSnapshots) {
frameSnapshots = []; frameSnapshots = [];
this._frameSnapshots.set(key, frameSnapshots); this._frameSnapshots.set(snapshot.frameId, frameSnapshots);
} }
frameSnapshots.push(snapshot); 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 { resourceContent(sha1: string): Buffer | undefined {
@ -87,4 +112,8 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
snapshotById(snapshotId: string): SnapshotRenderer | undefined { snapshotById(snapshotId: string): SnapshotRenderer | undefined {
return this._snapshots.get(snapshotId); return this._snapshots.get(snapshotId);
} }
frameSnapshots(frameId: string): FrameSnapshot[] {
return this._frameSnapshots.get(frameId) || [];
}
} }

View file

@ -38,6 +38,8 @@ export type FrameSnapshot = {
pageId: string, pageId: string,
frameId: string, frameId: string,
frameUrl: string, frameUrl: string,
pageTimestamp: number,
collectionTime: number,
doctype?: string, doctype?: string,
html: NodeSnapshot, html: NodeSnapshot,
resourceOverrides: ResourceOverride[], resourceOverrides: ResourceOverride[],

View file

@ -20,11 +20,13 @@ export class SnapshotRenderer {
private _snapshots: FrameSnapshot[]; private _snapshots: FrameSnapshot[];
private _index: number; private _index: number;
private _contextResources: ContextResources; private _contextResources: ContextResources;
readonly snapshotId: string;
constructor(contextResources: ContextResources, snapshots: FrameSnapshot[], index: number) { constructor(contextResources: ContextResources, snapshots: FrameSnapshot[], index: number) {
this._contextResources = contextResources; this._contextResources = contextResources;
this._snapshots = snapshots; this._snapshots = snapshots;
this._index = index; this._index = index;
this.snapshotId = snapshots[index].snapshotId;
} }
render(): RenderedFrameSnapshot { render(): RenderedFrameSnapshot {

View file

@ -53,25 +53,30 @@ export class Snapshotter {
private _context: BrowserContext; private _context: BrowserContext;
private _delegate: SnapshotterDelegate; private _delegate: SnapshotterDelegate;
private _eventListeners: RegisteredListener[] = []; private _eventListeners: RegisteredListener[] = [];
private _interval = 0;
constructor(context: BrowserContext, delegate: SnapshotterDelegate) { constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
this._context = context; this._context = context;
this._delegate = delegate; this._delegate = delegate;
} for (const page of context.pages())
this._onPage(page);
async start() {
this._eventListeners = [ this._eventListeners = [
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)), helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
]; ];
}
async initialize() {
await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => { await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
const snapshot: FrameSnapshot = { const snapshot: FrameSnapshot = {
snapshotId: data.snapshotId, snapshotId: data.snapshotId,
pageId: source.page.idInSnapshot, pageId: source.page.uniqueId,
frameId: source.frame.idInSnapshot, frameId: source.frame.uniqueId,
frameUrl: data.url, frameUrl: data.url,
doctype: data.doctype, doctype: data.doctype,
html: data.html, html: data.html,
viewport: data.viewport, viewport: data.viewport,
pageTimestamp: data.timestamp,
collectionTime: data.collectionTime,
resourceOverrides: [], resourceOverrides: [],
}; };
for (const { url, content } of data.resourceOverrides) { for (const { url, content } of data.resourceOverrides) {
@ -88,49 +93,57 @@ export class Snapshotter {
}); });
const initScript = '(' + frameSnapshotStreamer.toString() + ')()'; const initScript = '(' + frameSnapshotStreamer.toString() + ')()';
await this._context._doAddInitScript(initScript); await this._context._doAddInitScript(initScript);
const frames = [];
for (const page of this._context.pages()) 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() { dispose() {
helper.removeEventListeners(this._eventListeners); helper.removeEventListeners(this._eventListeners);
} }
async forceSnapshot(page: Page, snapshotId: string) { captureSnapshot(page: Page, snapshotId: string) {
await Promise.all([ // This needs to be sync, as in not awaiting for anything before we issue the command.
page.frames().forEach(async frame => { const expression = `window[${JSON.stringify(kSnapshotStreamer)}].captureSnapshot(${JSON.stringify(snapshotId)})`;
try { const snapshotFrame = (frame: Frame) => {
const context = await frame._mainContext(); const context = frame._existingMainContext();
await context.evaluateInternal(({ kSnapshotStreamer, snapshotId }) => { context?.rawEvaluate(expression).catch(debugExceptionHandler);
// Do not block action execution on the actual snapshot. };
Promise.resolve().then(() => (window as any)[kSnapshotStreamer].forceSnapshot(snapshotId)); page.frames().map(frame => snapshotFrame(frame));
return undefined; }
}, { kSnapshotStreamer, snapshotId });
} catch (e) { 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) { 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._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
this._saveResource(page, response).catch(e => debugLogger.log('error', e)); 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) { 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 body = await response.body().catch(e => debugLogger.log('error', e));
const responseSha1 = body ? calculateSha1(body) : 'none'; const responseSha1 = body ? calculateSha1(body) : 'none';
const resource: SnapshotterResource = { const resource: SnapshotterResource = {
pageId: page.idInSnapshot, pageId: page.uniqueId,
frameId: response.frame().idInSnapshot, frameId: response.frame().uniqueId,
resourceId: 'resource@' + createGuid(), resourceId: 'resource@' + createGuid(),
url, url,
contentType, contentType,
@ -177,3 +190,29 @@ export class Snapshotter {
this._delegate.onBlob({ sha1: responseSha1, buffer: body }); 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);
}

View file

@ -27,6 +27,8 @@ export type SnapshotData = {
viewport: { width: number, height: number }, viewport: { width: number, height: number },
url: string, url: string,
snapshotId: string, snapshotId: string,
timestamp: number,
collectionTime: number,
}; };
export const kSnapshotStreamer = '__playwright_snapshot_streamer_'; 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 _readingStyleSheet = false; // To avoid invalidating due to our own reads.
private _fakeBase: HTMLBaseElement; private _fakeBase: HTMLBaseElement;
private _observer: MutationObserver; private _observer: MutationObserver;
private _interval = 0;
constructor() { constructor() {
this._interceptNativeMethod(window.CSSStyleSheet.prototype, 'insertRule', (sheet: CSSStyleSheet) => this._invalidateStyleSheet(sheet)); 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)); this._observer = new MutationObserver(list => this._handleMutations(list));
const observerConfig = { attributes: true, subtree: true }; const observerConfig = { attributes: true, subtree: true };
this._observer.observe(document, observerConfig); this._observer.observe(document, observerConfig);
this._streamSnapshot('snapshot@initial');
} }
private _interceptNativeMethod(obj: any, method: string, cb: (thisObj: any, result: any) => void) { private _interceptNativeMethod(obj: any, method: string, cb: (thisObj: any, result: any) => void) {
@ -167,21 +168,29 @@ export function frameSnapshotStreamer() {
(iframeElement as any)[kSnapshotFrameId] = frameId; (iframeElement as any)[kSnapshotFrameId] = frameId;
} }
forceSnapshot(snapshotId: string) { captureSnapshot(snapshotId: string) {
this._streamSnapshot(snapshotId); 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) { if (this._timer) {
clearTimeout(this._timer); clearTimeout(this._timer);
this._timer = undefined; this._timer = undefined;
} }
try { try {
const snapshot = this._captureSnapshot(snapshotId); const snapshot = this._captureSnapshot(snapshotId, explicitRequest);
(window as any)[kSnapshotBinding](snapshot).catch((e: any) => {}); if (snapshot)
(window as any)[kSnapshotBinding](snapshot);
} catch (e) { } 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 { 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; const snapshotNumber = ++this._lastSnapshotNumber;
let nodeCounter = 0; let nodeCounter = 0;
let shadowDomNesting = 0; let shadowDomNesting = 0;
@ -396,10 +406,14 @@ export function frameSnapshotStreamer() {
}; };
let html: NodeSnapshot; let html: NodeSnapshot;
if (document.documentElement) let htmlEquals = false;
html = visitNode(document.documentElement)!.n; if (document.documentElement) {
else const { equals, n } = visitNode(document.documentElement)!;
htmlEquals = equals;
html = n;
} else {
html = ['html']; html = ['html'];
}
const result: SnapshotData = { const result: SnapshotData = {
html, html,
@ -411,19 +425,27 @@ export function frameSnapshotStreamer() {
}, },
url: location.href, url: location.href,
snapshotId, snapshotId,
timestamp,
collectionTime: 0,
}; };
let allOverridesAreRefs = true;
for (const sheet of this._allStyleSheetsWithUrlOverride) { for (const sheet of this._allStyleSheetsWithUrlOverride) {
const content = this._updateLinkStyleSheetTextIfNeeded(sheet, snapshotNumber); const content = this._updateLinkStyleSheetTextIfNeeded(sheet, snapshotNumber);
if (content === undefined) { if (content === undefined) {
// Unable to capture stylsheet contents. // Unable to capture stylsheet contents.
continue; continue;
} }
if (typeof content !== 'number')
allOverridesAreRefs = false;
const base = this._getSheetBase(sheet); const base = this._getSheetBase(sheet);
const url = removeHash(this._resolveUrl(base, sheet.href!)); const url = removeHash(this._resolveUrl(base, sheet.href!));
result.resourceOverrides.push({ url, content }); result.resourceOverrides.push({ url, content });
} }
result.collectionTime = performance.now() - result.timestamp;
if (!explicitRequest && htmlEquals && allOverridesAreRefs)
return undefined;
return result; return result;
} }
} }

View file

@ -131,7 +131,7 @@ export class RecorderSupplement {
const recorderApp = await RecorderApp.open(this._context); const recorderApp = await RecorderApp.open(this._context);
this._recorderApp = recorderApp; this._recorderApp = recorderApp;
recorderApp.once('close', () => { recorderApp.once('close', () => {
this._snapshotter.stop(); this._snapshotter.dispose().catch(() => {});
this._recorderApp = null; this._recorderApp = null;
}); });
recorderApp.on('event', (data: EventData) => { recorderApp.on('event', (data: EventData) => {
@ -235,7 +235,7 @@ export class RecorderSupplement {
this._resume(false).catch(() => {}); 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(recorderSource.source, { isUnderTest: isUnderTest(), snapshotBaseUrl });
await this._context.extendInjectedScript(consoleApiSource.source); await this._context.extendInjectedScript(consoleApiSource.source);
(this._context as any).recorderAppForTest = recorderApp; (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) }); 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) { if (sdkObject.attribution.page) {
const snapshotId = `${phase}@${metadata.id}`; const snapshotId = `${phase}@${metadata.id}`;
this._snapshots.add(snapshotId); 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> { async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._mode === 'recording') if (this._mode === 'recording')
return; return;
await this._captureSnapshot(sdkObject, metadata, 'before'); this._captureSnapshot(sdkObject, metadata, 'before');
this._currentCallsMetadata.set(metadata, sdkObject); this._currentCallsMetadata.set(metadata, sdkObject);
this._allMetadatas.set(metadata.id, metadata); this._allMetadatas.set(metadata.id, metadata);
this._updateUserSources(); this._updateUserSources();

View file

@ -112,7 +112,8 @@ class ContextTracer implements SnapshotterDelegate {
} }
async start() { async start() {
await this._snapshotter.start(); await this._snapshotter.initialize();
await this._snapshotter.setAutoSnapshotInterval(100);
} }
onBlob(blob: SnapshotterBlob): void { onBlob(blob: SnapshotterBlob): void {
@ -156,7 +157,7 @@ class ContextTracer implements SnapshotterDelegate {
return; return;
const snapshotId = createGuid(); const snapshotId = createGuid();
snapshotsForMetadata(metadata).push({ name, snapshotId }); 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> { async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
@ -166,7 +167,7 @@ class ContextTracer implements SnapshotterDelegate {
timestamp: monotonicTime(), timestamp: monotonicTime(),
type: 'action', type: 'action',
contextId: this._contextId, contextId: this._contextId,
pageId: sdkObject.attribution.page.idInSnapshot, pageId: sdkObject.attribution.page.uniqueId,
objectType: metadata.type, objectType: metadata.type,
method: metadata.method, method: metadata.method,
// FIXME: filter out evaluation snippets, binary // FIXME: filter out evaluation snippets, binary
@ -182,7 +183,7 @@ class ContextTracer implements SnapshotterDelegate {
} }
private _onPage(page: Page) { private _onPage(page: Page) {
const pageId = page.idInSnapshot; const pageId = page.uniqueId;
const event: trace.PageCreatedTraceEvent = { const event: trace.PageCreatedTraceEvent = {
timestamp: monotonicTime(), timestamp: monotonicTime(),

View file

@ -21,7 +21,7 @@ import { msToString } from '../uiUtils';
export interface CallLogProps { export interface CallLogProps {
log: CallLog[], 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> = ({ export const CallLogView: React.FC<CallLogProps> = ({
@ -52,9 +52,9 @@ export const CallLogView: React.FC<CallLogProps> = ({
<span className={'codicon ' + iconClass(callLog)}></span> <span className={'codicon ' + iconClass(callLog)}></span>
{ typeof callLog.duration === 'number' ? <span className='call-log-time'> {msToString(callLog.duration)}</span> : undefined} { typeof callLog.duration === 'number' ? <span className='call-log-time'> {msToString(callLog.duration)}</span> : undefined}
{ <div style={{flex: 'auto'}}></div> } { <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-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.id, 'in')} 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.id, 'after')} onMouseLeave={() => onHover(undefined)}></span> <span className={'codicon codicon-vm-active preview' + (callLog.snapshots.after ? '' : ' invisible')} onMouseEnter={() => onHover(callLog, 'after')} onMouseLeave={() => onHover(undefined)}></span>
</div> </div>
{ (isExpanded ? callLog.messages : []).map((message, i) => { { (isExpanded ? callLog.messages : []).map((message, i) => {
return <div className='call-log-message' key={i}> return <div className='call-log-message' key={i}>

View file

@ -81,19 +81,19 @@ export const Recorder: React.FC<RecorderProps> = ({
return <div className='recorder'> return <div className='recorder'>
<Toolbar> <Toolbar>
<ToolbarButton icon='record' title='Record' toggled={mode == 'recording'} onClick={() => { <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> }}>Record</ToolbarButton>
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => { <ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
copy(source.text); copy(source.text);
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon='debug-continue' title='Resume' disabled={!paused} onClick={() => { <ToolbarButton icon='debug-continue' title='Resume' disabled={!paused} onClick={() => {
window.dispatch({ event: 'resume' }).catch(() => {}); window.dispatch({ event: 'resume' });
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon='debug-pause' title='Pause' disabled={paused} onClick={() => { <ToolbarButton icon='debug-pause' title='Pause' disabled={paused} onClick={() => {
window.dispatch({ event: 'pause' }).catch(() => {}); window.dispatch({ event: 'pause' });
}}></ToolbarButton> }}></ToolbarButton>
<ToolbarButton icon='debug-step-over' title='Step over' disabled={!paused} onClick={() => { <ToolbarButton icon='debug-step-over' title='Step over' disabled={!paused} onClick={() => {
window.dispatch({ event: 'step' }).catch(() => {}); window.dispatch({ event: 'step' });
}}></ToolbarButton> }}></ToolbarButton>
<select className='recorder-chooser' hidden={!sources.length} value={file} onChange={event => { <select className='recorder-chooser' hidden={!sources.length} value={file} onChange={event => {
setFile(event.target.selectedOptions[0].value); setFile(event.target.selectedOptions[0].value);
@ -106,7 +106,7 @@ export const Recorder: React.FC<RecorderProps> = ({
</select> </select>
<div style={{flex: 'auto'}}></div> <div style={{flex: 'auto'}}></div>
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => { <ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
window.dispatch({ event: 'clear' }).catch(() => {}); window.dispatch({ event: 'clear' });
}}></ToolbarButton> }}></ToolbarButton>
</Toolbar> </Toolbar>
<SplitView sidebarSize={200} sidebarHidden={mode === 'recording'}> <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 } }); window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
}} /> }} />
</Toolbar> </Toolbar>
<CallLogView log={Array.from(log.values())} onHover={(callLogId, phase) => { <CallLogView log={Array.from(log.values())} onHover={(callLog, phase) => {
window.dispatch({ event: 'callLogHovered', params: { callLogId, phase } }).catch(() => {}); window.dispatch({ event: 'callLogHovered', params: { callLogId: callLog?.id, phase } });
}}/> }}/>
</div> </div>
</SplitView> </SplitView>

139
test/snapshotter.spec.ts Normal file
View 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>/, '');
}