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;
}
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);

View file

@ -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');
}

View file

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

View file

@ -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
}

View file

@ -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 = () => {};

View file

@ -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) || [];
}
}

View file

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

View file

@ -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 {

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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(),

View file

@ -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}>

View file

@ -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
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>/, '');
}