chore(tracing): fix some of the start/stop scenarios (#6337)

This commit is contained in:
Pavel Feldman 2021-04-27 11:07:07 -07:00 committed by GitHub
parent abb61456d1
commit 922d9ce1fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 132 additions and 143 deletions

View File

@ -29,7 +29,7 @@ import * as types from './types';
import path from 'path';
import { CallMetadata, internalCallMetadata, createInstrumentation, SdkObject } from './instrumentation';
import { Debugger } from './supplements/debugger';
import { Tracer } from './trace/recorder/tracer';
import { Tracing } from './trace/recorder/tracing';
import { HarTracer } from './supplements/har/harTracer';
import { RecorderSupplement } from './supplements/recorderSupplement';
import * as consoleApiSource from '../generated/consoleApiSource';
@ -57,7 +57,7 @@ export abstract class BrowserContext extends SdkObject {
private _selectors?: Selectors;
private _origins = new Set<string>();
private _harTracer: HarTracer | undefined;
readonly tracing: Tracer;
readonly tracing: Tracing;
constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
super(browser, 'browser-context');
@ -70,7 +70,7 @@ export abstract class BrowserContext extends SdkObject {
if (this._options.recordHar)
this._harTracer = new HarTracer(this, this._options.recordHar);
this.tracing = new Tracer(this);
this.tracing = new Tracing(this);
}
_setSelectors(selectors: Selectors) {
@ -264,7 +264,7 @@ export abstract class BrowserContext extends SdkObject {
this._closedStatus = 'closing';
await this._harTracer?.flush();
await this.tracing.stop();
await this.tracing.dispose();
// Cleanup.
const promises: Promise<void>[] = [];

View File

@ -71,6 +71,7 @@ export class FrameManager {
readonly _consoleMessageTags = new Map<string, ConsoleTagHandler>();
readonly _signalBarriers = new Set<SignalBarrier>();
private _webSockets = new Map<string, network.WebSocket>();
readonly _responses: network.Response[] = [];
constructor(page: Page) {
this._page = page;
@ -198,6 +199,7 @@ export class FrameManager {
frame._onClearLifecycle();
const navigationEvent: NavigationEvent = { url, name, newDocument: frame._currentDocument };
frame.emit(Frame.Events.Navigation, navigationEvent);
this._responses.length = 0;
if (!initial) {
debugLogger.log('api', ` navigated to "${url}"`);
this._page.frameNavigatedToNewDocument(frame);
@ -264,8 +266,10 @@ export class FrameManager {
}
requestReceivedResponse(response: network.Response) {
if (!response.request()._isFavicon)
this._page.emit(Page.Events.Response, response);
if (response.request()._isFavicon)
return;
this._responses.push(response);
this._page.emit(Page.Events.Response, response);
}
requestFinished(request: network.Request) {

View File

@ -38,7 +38,7 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
}
async initialize(): Promise<string> {
await this._snapshotter.initialize();
await this._snapshotter.start();
return await this._server.start();
}
@ -62,10 +62,6 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
});
}
async setAutoSnapshotIntervalForTest(interval: number): Promise<void> {
await this._snapshotter.setAutoSnapshotInterval(interval);
}
onBlob(blob: SnapshotterBlob): void {
this._blobs.set(blob.sha1, blob.buffer);
}

View File

@ -40,24 +40,46 @@ export class Snapshotter {
private _context: BrowserContext;
private _delegate: SnapshotterDelegate;
private _eventListeners: RegisteredListener[] = [];
private _interval = 0;
private _snapshotStreamer: string;
private _snapshotBinding: string;
private _initialized = false;
private _started = false;
private _fetchedResponses = new Map<network.Response, string>();
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
this._context = context;
this._delegate = delegate;
for (const page of context.pages())
this._onPage(page);
this._eventListeners = [
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
const guid = createGuid();
this._snapshotStreamer = '__playwright_snapshot_streamer_' + guid;
this._snapshotBinding = '__playwright_snapshot_binding_' + guid;
}
async initialize() {
async start() {
this._started = true;
if (!this._initialized) {
this._initialized = true;
await this._initialize();
}
this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`);
// Replay resources loaded in all pages.
for (const page of this._context.pages()) {
for (const response of page._frameManager._responses)
this._saveResource(page, response).catch(e => debugLogger.log('error', e));
}
}
async stop() {
this._started = false;
}
async _initialize() {
for (const page of this._context.pages())
this._onPage(page);
this._eventListeners = [
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
await this._context.exposeBinding(this._snapshotBinding, false, (source, data: SnapshotData) => {
const snapshot: FrameSnapshot = {
snapshotName: data.snapshotName,
@ -87,11 +109,15 @@ export class Snapshotter {
});
const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}", "${this._snapshotBinding}")`;
await this._context._doAddInitScript(initScript);
this._runInAllFrames(initScript);
}
private _runInAllFrames(expression: string) {
const frames = [];
for (const page of this._context.pages())
frames.push(...page.frames());
frames.map(frame => {
frame._existingMainContext()?.rawEvaluate(initScript).catch(debugExceptionHandler);
frame._existingMainContext()?.rawEvaluate(expression).catch(debugExceptionHandler);
});
}
@ -112,37 +138,20 @@ export class Snapshotter {
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 => this._setIntervalInFrame(frame, interval)));
}
private _onPage(page: Page) {
const processNewFrame = (frame: Frame) => {
this._annotateFrameHierarchy(frame);
this._setIntervalInFrame(frame, this._interval);
const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}", "${this._snapshotBinding}")`;
frame._existingMainContext()?.rawEvaluate(initScript).catch(debugExceptionHandler);
};
// Annotate frame hierarchy so that snapshots could include frame ids.
for (const frame of page.frames())
processNewFrame(frame);
this._eventListeners.push(helper.addEventListener(page, Page.Events.FrameAttached, processNewFrame));
this._annotateFrameHierarchy(frame);
this._eventListeners.push(helper.addEventListener(page, Page.Events.FrameAttached, frame => this._annotateFrameHierarchy(frame)));
// Push streamer interval on navigation.
this._eventListeners.push(helper.addEventListener(page, Page.Events.InternalFrameNavigatedToNewDocument, frame => {
this._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));
}));
}
private async _saveResource(page: Page, response: network.Response) {
if (!this._started)
return;
const isRedirect = response.status() >= 300 && response.status() <= 399;
if (isRedirect)
return;
@ -163,9 +172,25 @@ export class Snapshotter {
const status = response.status();
const requestBody = original.postDataBuffer();
const requestSha1 = requestBody ? calculateSha1(requestBody) : '';
if (requestBody)
this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody });
const requestHeaders = original.headers();
const body = await response.body().catch(e => debugLogger.log('error', e));
const responseSha1 = body ? calculateSha1(body) : '';
// Only fetch response bodies once.
let responseSha1 = this._fetchedResponses.get(response);
{
if (responseSha1 === undefined) {
const body = await response.body().catch(e => debugLogger.log('error', e));
// Bail out after each async hop.
if (!this._started)
return;
responseSha1 = body ? calculateSha1(body) : '';
if (body)
this._delegate.onBlob({ sha1: responseSha1, buffer: body });
this._fetchedResponses.set(response, responseSha1);
}
}
const resource: ResourceSnapshot = {
pageId: page.guid,
frameId: response.frame().guid,
@ -181,17 +206,6 @@ export class Snapshotter {
timestamp: monotonicTime()
};
this._delegate.onResourceSnapshot(resource);
if (requestBody)
this._delegate.onBlob({ sha1: requestSha1, buffer: requestBody });
if (body)
this._delegate.onBlob({ sha1: responseSha1, buffer: body });
}
private async _setIntervalInFrame(frame: Frame, interval: number) {
const context = frame._existingMainContext();
await context?.evaluate(({ snapshotStreamer, interval }) => {
(window as any)[snapshotStreamer].setSnapshotInterval(interval);
}, { snapshotStreamer: this._snapshotStreamer, interval }).catch(debugExceptionHandler);
}
private async _annotateFrameHierarchy(frame: Frame) {

View File

@ -51,6 +51,11 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding:
cssText?: string, // Text for stylesheets.
cssRef?: number, // Previous snapshotNumber for overridden stylesheets.
};
function resetCachedData(obj: any) {
delete obj[kCachedData];
}
function ensureCachedData(obj: any): CachedData {
if (!obj[kCachedData])
obj[kCachedData] = {};
@ -69,14 +74,11 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding:
class Streamer {
private _removeNoScript = true;
private _timer: NodeJS.Timeout | undefined;
private _lastSnapshotNumber = 0;
private _staleStyleSheets = new Set<CSSStyleSheet>();
private _allStyleSheetsWithUrlOverride = new Set<CSSStyleSheet>();
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));
@ -125,8 +127,6 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding:
if (this._readingStyleSheet)
return;
this._staleStyleSheets.add(sheet);
if (sheet.href !== null)
this._allStyleSheetsWithUrlOverride.add(sheet);
}
private _updateStyleElementStyleSheetTextIfNeeded(sheet: CSSStyleSheet): string | undefined {
@ -162,29 +162,29 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding:
(iframeElement as any)[kSnapshotFrameId] = frameId;
}
captureSnapshot(snapshotName?: string) {
this._streamSnapshot(snapshotName);
reset() {
this._staleStyleSheets.clear();
const visitNode = (node: Node | ShadowRoot) => {
resetCachedData(node);
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (element.shadowRoot)
visitNode(element.shadowRoot);
}
for (let child = node.firstChild; child; child = child.nextSibling)
visitNode(child);
};
visitNode(document.documentElement);
}
setSnapshotInterval(interval: number) {
this._interval = interval;
if (interval)
this._streamSnapshot();
}
private _streamSnapshot(snapshotName?: string) {
if (this._timer) {
clearTimeout(this._timer);
this._timer = undefined;
}
captureSnapshot(snapshotName: string) {
try {
const snapshot = this._captureSnapshot(snapshotName);
if (snapshot)
(window as any)[snapshotBinding](snapshot);
} catch (e) {
}
if (this._interval)
this._timer = setTimeout(() => this._streamSnapshot(), this._interval);
}
private _sanitizeUrl(url: string): string {
@ -298,11 +298,11 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding:
const result: NodeSnapshot = [nodeName, attrs];
const visitChild = (child: Node) => {
const snapshotted = visitNode(child);
if (snapshotted) {
result.push(snapshotted.n);
const snapshot = visitNode(child);
if (snapshot) {
result.push(snapshot.n);
expectValue(child);
equals = equals && snapshotted.equals;
equals = equals && snapshot.equals;
}
};
@ -432,10 +432,12 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding:
};
let allOverridesAreRefs = true;
for (const sheet of this._allStyleSheetsWithUrlOverride) {
for (const sheet of this._staleStyleSheets) {
if (sheet.href === null)
continue;
const content = this._updateLinkStyleSheetTextIfNeeded(sheet, snapshotNumber);
if (content === undefined) {
// Unable to capture stylsheet contents.
// Unable to capture stylesheet contents.
continue;
}
if (typeof content !== 'number')

View File

@ -43,7 +43,7 @@ export class TraceSnapshotter extends EventEmitter implements SnapshotterDelegat
}
async start(): Promise<void> {
await this._snapshotter.initialize();
await this._snapshotter.start();
}
async dispose() {

View File

@ -18,7 +18,7 @@ import fs from 'fs';
import path from 'path';
import util from 'util';
import yazl from 'yazl';
import { createGuid, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
import { calculateSha1, createGuid, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
import { Artifact } from '../../artifact';
import { BrowserContext } from '../../browserContext';
import { Dialog } from '../../dialog';
@ -40,14 +40,14 @@ export type TracerOptions = {
screenshots?: boolean;
};
export class Tracer implements InstrumentationListener {
export class Tracing implements InstrumentationListener {
private _appendEventChain = Promise.resolve();
private _snapshotter: TraceSnapshotter | undefined;
private _snapshotter: TraceSnapshotter;
private _eventListeners: RegisteredListener[] = [];
private _pendingCalls = new Map<string, { sdkObject: SdkObject, metadata: CallMetadata }>();
private _context: BrowserContext;
private _traceFile: string | undefined;
private _resourcesDir: string | undefined;
private _resourcesDir: string;
private _sha1s: string[] = [];
private _started = false;
private _traceDir: string | undefined;
@ -55,19 +55,18 @@ export class Tracer implements InstrumentationListener {
constructor(context: BrowserContext) {
this._context = context;
this._traceDir = context._browser.options.traceDir;
this._resourcesDir = path.join(this._traceDir || '', 'resources');
this._snapshotter = new TraceSnapshotter(this._context, this._resourcesDir, traceEvent => this._appendTraceEvent(traceEvent));
}
async start(options: TracerOptions): Promise<void> {
// context + page must be the first events added, this method can't have awaits before them.
if (!this._traceDir)
throw new Error('Tracing directory is not specified when launching the browser');
if (this._started)
throw new Error('Tracing has already been started');
this._started = true;
this._traceFile = path.join(this._traceDir, (options.name || createGuid()) + '.trace');
if (options.screenshots || options.snapshots) {
this._resourcesDir = path.join(this._traceDir, 'resources');
await fsMkdirAsync(this._resourcesDir, { recursive: true });
}
this._appendEventChain = mkdirIfNeeded(this._traceFile);
const event: trace.ContextCreatedTraceEvent = {
@ -80,13 +79,18 @@ export class Tracer implements InstrumentationListener {
debugName: this._context._options._debugName,
};
this._appendTraceEvent(event);
for (const page of this._context.pages())
this._onPage(options.screenshots, page);
this._eventListeners.push(
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this, options.screenshots)),
);
// context + page must be the first events added, no awaits above this line.
await fsMkdirAsync(this._resourcesDir, { recursive: true });
this._context.instrumentation.addListener(this);
if (options.snapshots)
this._snapshotter = new TraceSnapshotter(this._context, this._resourcesDir!, traceEvent => this._appendTraceEvent(traceEvent));
await this._snapshotter?.start();
await this._snapshotter.start();
}
async stop(): Promise<void> {
@ -95,8 +99,6 @@ export class Tracer implements InstrumentationListener {
this._started = false;
this._context.instrumentation.removeListener(this);
helper.removeEventListeners(this._eventListeners);
await this._snapshotter?.dispose();
this._snapshotter = undefined;
for (const { sdkObject, metadata } of this._pendingCalls.values())
this.onAfterCall(sdkObject, metadata);
for (const page of this._context.pages())
@ -106,6 +108,10 @@ export class Tracer implements InstrumentationListener {
await this._appendEventChain;
}
async dispose() {
await this._snapshotter.dispose();
}
async export(): Promise<Artifact> {
if (!this._traceFile)
throw new Error('Tracing directory is not specified when launching the browser');
@ -228,11 +234,11 @@ export class Tracer implements InstrumentationListener {
}),
helper.addEventListener(page, Page.Events.ScreencastFrame, params => {
const guid = createGuid();
const sha1 = calculateSha1(createGuid()); // no need to compute sha1 for screenshots
const event: trace.ScreencastFrameTraceEvent = {
type: 'screencast-frame',
pageId: page.guid,
sha1: guid, // no need to compute sha1 for screenshots
sha1,
pageTimestamp: params.timestamp,
width: params.width,
height: params.height,
@ -240,7 +246,7 @@ export class Tracer implements InstrumentationListener {
};
this._appendTraceEvent(event);
this._appendEventChain = this._appendEventChain.then(async () => {
await fsWriteFileAsync(path.join(this._resourcesDir!, guid), params.buffer).catch(() => {});
await fsWriteFileAsync(path.join(this._resourcesDir!, sha1), params.buffer).catch(() => {});
});
}),

View File

@ -83,7 +83,7 @@ const FilmStripLane: React.FunctionComponent<{
const frames: JSX.Element[] = [];
let i = 0;
for (let time = startTime; time <= endTime; time += frameDuration, ++i) {
for (let time = startTime; startTime && frameDuration && time <= endTime; time += frameDuration, ++i) {
const index = upperBound(screencastFrames, time, timeComparator) - 1;
frames.push(<div className='film-strip-frame' key={i} style={{
width: frameSize.width,

View File

@ -67,62 +67,29 @@ it.describe('snapshots', () => {
expect(snapshots.length).toBe(2);
});
it('should only collect on change', async ({ 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.setAutoSnapshotIntervalForTest(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 ({ page }) => {
it('should respect inline CSSOM change', async ({ page, toImpl }) => {
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.setAutoSnapshotIntervalForTest(25),
]);
expect(distillSnapshot(snapshots[0])).toBe('<style>button { color: red; }</style><BUTTON>Hello</BUTTON>');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
expect(distillSnapshot(snapshot1)).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>');
await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; });
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
expect(distillSnapshot(snapshot2)).toBe('<style>button { color: blue; }</style><BUTTON>Hello</BUTTON>');
});
it('should respect subresource CSSOM change', async ({ page, server }) => {
it('should respect subresource CSSOM change', async ({ page, server, toImpl }) => {
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.setAutoSnapshotIntervalForTest(25),
]);
expect(distillSnapshot(snapshots[0])).toBe('<LINK rel=\"stylesheet\" href=\"style.css\"><BUTTON>Hello</BUTTON>');
const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
expect(distillSnapshot(snapshot1)).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();
await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; });
const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1');
const { resources } = snapshot2.render();
const cssHref = `http://localhost:${server.PORT}/style.css`;
const { sha1 } = resources[cssHref];
expect(snapshotter.resourceContent(sha1).toString()).toBe('button { color: blue; }');

View File

@ -141,7 +141,7 @@ DEPS['src/server/android/'] = [...DEPS['src/server/'], 'src/server/chromium/', '
DEPS['src/server/electron/'] = [...DEPS['src/server/'], 'src/server/chromium/'];
DEPS['src/server/playwright.ts'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/server/webkit/', 'src/server/firefox/', 'src/server/android/', 'src/server/electron/'];
DEPS['src/server/browserContext.ts'] = [...DEPS['src/server/'], 'src/server/trace/recorder/tracer.ts'];
DEPS['src/server/browserContext.ts'] = [...DEPS['src/server/'], 'src/server/trace/recorder/tracing.ts'];
DEPS['src/cli/driver.ts'] = DEPS['src/inprocess.ts'] = DEPS['src/browserServerImpl.ts'] = ['src/**'];
// Tracing is a client/server plugin, nothing should depend on it.