From b253ee80c021825568011ba5a994798c229e5400 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 1 Mar 2021 12:20:04 -0800 Subject: [PATCH] chore(snapshot): brush up, start adding tests (#5646) --- src/dispatchers/dispatcher.ts | 4 +- src/server/frames.ts | 8 +- src/server/instrumentation.ts | 2 + src/server/javascript.ts | 5 + src/server/page.ts | 4 +- src/server/snapshot/inMemorySnapshotter.ts | 53 +++++-- src/server/snapshot/snapshot.ts | 2 + src/server/snapshot/snapshotRenderer.ts | 2 + src/server/snapshot/snapshotter.ts | 113 ++++++++++----- src/server/snapshot/snapshotterInjected.ts | 46 ++++-- src/server/supplements/recorderSupplement.ts | 10 +- src/server/trace/recorder/tracer.ts | 9 +- src/web/recorder/callLog.tsx | 8 +- src/web/recorder/recorder.tsx | 14 +- test/snapshotter.spec.ts | 139 +++++++++++++++++++ 15 files changed, 333 insertions(+), 86 deletions(-) create mode 100644 test/snapshotter.spec.ts diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index 5087554045..bd8909d7a7 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -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); diff --git a/src/server/frames.ts b/src/server/frames.ts index 3a8b3291e5..bf7ecd1ac7 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -411,11 +411,11 @@ export class Frame extends SdkObject { private _setContentCounter = 0; readonly _detachedPromise: Promise; 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 { return this._context('utility'); } diff --git a/src/server/instrumentation.ts b/src/server/instrumentation.ts index e15abf7767..6e09d2a99f 100644 --- a/src/server/instrumentation.ts +++ b/src/server/instrumentation.ts @@ -44,6 +44,8 @@ export type CallMetadata = { log: string[]; error?: string; point?: Point; + pageId?: string; + frameId?: string; }; export class SdkObject extends EventEmitter { diff --git a/src/server/javascript.ts b/src/server/javascript.ts index b8056a46bd..5ac41877bf 100644 --- a/src/server/javascript.ts +++ b/src/server/javascript.ts @@ -79,6 +79,11 @@ export class ExecutionContext extends SdkObject { return this._delegate.createHandle(this, remoteObject); } + async rawEvaluate(expression: string): Promise { + // Make sure to never return a value. + await this._delegate.rawEvaluate(expression + '; 0'); + } + async doSlowMo() { // overrided in FrameExecutionContext } diff --git a/src/server/page.ts b/src/server/page.ts index 54c8f9d392..1d6c91709b 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -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 = () => {}; diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index 59437a333f..6ec2a30e5f 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -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(); private _resources = new Map(); private _frameSnapshots = new Map(); @@ -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 { - await this._snapshotter.start(); + async initialize(): Promise { + await this._snapshotter.initialize(); return await this._server.start(); } - stop() { - this._snapshotter.dispose(); - this._server.stop().catch(() => {}); + async start(): Promise { + 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 { + if (this._snapshots.has(snapshotId)) + throw new Error('Duplicate snapshotId: ' + snapshotId); + + this._snapshotter.captureSnapshot(page, snapshotId); + return new Promise(fulfill => { + const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => { + if (renderer.snapshotId === snapshotId) { + helper.removeEventListeners([listener]); + fulfill(renderer); + } + }); + }); + } + + async setAutoSnapshotInterval(interval: number): Promise { + 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) || []; + } } diff --git a/src/server/snapshot/snapshot.ts b/src/server/snapshot/snapshot.ts index 251b6904a4..b18a136ff3 100644 --- a/src/server/snapshot/snapshot.ts +++ b/src/server/snapshot/snapshot.ts @@ -38,6 +38,8 @@ export type FrameSnapshot = { pageId: string, frameId: string, frameUrl: string, + pageTimestamp: number, + collectionTime: number, doctype?: string, html: NodeSnapshot, resourceOverrides: ResourceOverride[], diff --git a/src/server/snapshot/snapshotRenderer.ts b/src/server/snapshot/snapshotRenderer.ts index 9c1c0f52c7..9c227a9930 100644 --- a/src/server/snapshot/snapshotRenderer.ts +++ b/src/server/snapshot/snapshotRenderer.ts @@ -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 { diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index 75f07ce80b..1ae07ca996 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -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 { + 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); +} diff --git a/src/server/snapshot/snapshotterInjected.ts b/src/server/snapshot/snapshotterInjected.ts index c6d4dcdd07..4124b40946 100644 --- a/src/server/snapshot/snapshotterInjected.ts +++ b/src/server/snapshot/snapshotterInjected.ts @@ -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; } } diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 2763ec4815..ec5de661d7 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -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 { 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(); diff --git a/src/server/trace/recorder/tracer.ts b/src/server/trace/recorder/tracer.ts index 80df0d7bd0..2594bfa39e 100644 --- a/src/server/trace/recorder/tracer.ts +++ b/src/server/trace/recorder/tracer.ts @@ -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 { @@ -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(), diff --git a/src/web/recorder/callLog.tsx b/src/web/recorder/callLog.tsx index a22b28f99f..732383124f 100644 --- a/src/web/recorder/callLog.tsx +++ b/src/web/recorder/callLog.tsx @@ -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 = ({ @@ -52,9 +52,9 @@ export const CallLogView: React.FC = ({ { typeof callLog.duration === 'number' ? — {msToString(callLog.duration)} : undefined} {
} - onHover(callLog.id, 'before')} onMouseLeave={() => onHover(undefined)}> - onHover(callLog.id, 'in')} onMouseLeave={() => onHover(undefined)}> - onHover(callLog.id, 'after')} onMouseLeave={() => onHover(undefined)}> + onHover(callLog, 'before')} onMouseLeave={() => onHover(undefined)}> + onHover(callLog, 'in')} onMouseLeave={() => onHover(undefined)}> + onHover(callLog, 'after')} onMouseLeave={() => onHover(undefined)}> { (isExpanded ? callLog.messages : []).map((message, i) => { return
diff --git a/src/web/recorder/recorder.tsx b/src/web/recorder/recorder.tsx index 1e74073f02..83f2d2299f 100644 --- a/src/web/recorder/recorder.tsx +++ b/src/web/recorder/recorder.tsx @@ -81,19 +81,19 @@ export const Recorder: React.FC = ({ return
{ - window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { }); + window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}); }}>Record { copy(source.text); }}> { - window.dispatch({ event: 'resume' }).catch(() => {}); + window.dispatch({ event: 'resume' }); }}> { - window.dispatch({ event: 'pause' }).catch(() => {}); + window.dispatch({ event: 'pause' }); }}> { - window.dispatch({ event: 'step' }).catch(() => {}); + window.dispatch({ event: 'step' }); }}>
{ - window.dispatch({ event: 'clear' }).catch(() => {}); + window.dispatch({ event: 'clear' }); }}>
@@ -121,8 +121,8 @@ export const Recorder: React.FC = ({ window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } }); }} /> - { - window.dispatch({ event: 'callLogHovered', params: { callLogId, phase } }).catch(() => {}); + { + window.dispatch({ event: 'callLogHovered', params: { callLogId: callLog?.id, phase } }); }}/>
diff --git a/test/snapshotter.spec.ts b/test/snapshotter.spec.ts new file mode 100644 index 0000000000..b570855ca3 --- /dev/null +++ b/test/snapshotter.spec.ts @@ -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(); +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(''); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + expect(distillSnapshot(snapshot)).toBe(''); + }); + + 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(''); + 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(''); + 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(''); + 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('') + ]); + expect(snapshots.length).toBe(2); + }); + + it('should respect inline CSSOM change', async ({ snapshotter, page }) => { + await page.setContent(''); + 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(''); + + 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(''); + }); + + 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(''); + + 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(''); + + 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(/