Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(trace): experimental traces for our tests #3567

Merged
merged 2 commits into from
Aug 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"roll-browser": "node utils/roll_browser.js",
"coverage": "node test/checkCoverage.js",
"check-deps": "node utils/check_deps.js",
"show-trace": "node utils/showTestTraces.js",
"build-testrunner": "tsc -p test-runner",
"test-testrunner": "node test-runner/cli test-runner/test"
},
Expand Down
4 changes: 2 additions & 2 deletions src/browserServerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import { LaunchServerOptions } from './client/types';
import { BrowserTypeBase } from './server/browserType';
import * as ws from 'ws';
import { helper } from './server/helper';
import { Browser } from './server/browser';
import { ChildProcess } from 'child_process';
import { EventEmitter } from 'ws';
Expand All @@ -28,6 +27,7 @@ import { BrowserContextDispatcher } from './dispatchers/browserContextDispatcher
import { BrowserNewContextParams, BrowserContextChannel } from './protocol/channels';
import { BrowserServerLauncher, BrowserServer } from './client/browserType';
import { envObjectToArray } from './client/clientHelper';
import { createGuid } from './utils/utils';

export class BrowserServerLauncherImpl implements BrowserServerLauncher {
private _browserType: BrowserTypeBase;
Expand Down Expand Up @@ -60,7 +60,7 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer {
this._browserType = browserType;
this._browser = browser;

const token = helper.guid();
const token = createGuid();
this._server = new ws.Server({ port });
const address = this._server.address();
this._wsEndpoint = typeof address === 'string' ? `${address}/${token}` : `ws://127.0.0.1:${address.port}/${token}`;
Expand Down
5 changes: 2 additions & 3 deletions src/dispatchers/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@
*/

import { EventEmitter } from 'events';
import { helper } from '../server/helper';
import * as channels from '../protocol/channels';
import { serializeError } from '../protocol/serializers';
import { createScheme, Validator, ValidationError } from '../protocol/validator';
import { assert, debugAssert } from '../utils/utils';
import { assert, createGuid, debugAssert } from '../utils/utils';

export const dispatcherSymbol = Symbol('dispatcher');

Expand Down Expand Up @@ -51,7 +50,7 @@ export class Dispatcher<Type, Initializer> extends EventEmitter implements chann
readonly _scope: Dispatcher<any, any>;
_object: Type;

constructor(parent: Dispatcher<any, any> | DispatcherConnection, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + helper.guid()) {
constructor(parent: Dispatcher<any, any> | DispatcherConnection, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + createGuid()) {
super();

this._connection = parent instanceof DispatcherConnection ? parent : parent._connection;
Expand Down
10 changes: 10 additions & 0 deletions src/server/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { EventEmitter } from 'events';
import { Progress } from './progress';
import { DebugController } from './debug/debugController';
import { isDebugMode } from '../utils/utils';
import { Snapshotter, SnapshotterDelegate } from './snapshotter';

export class Screencast {
readonly page: Page;
Expand Down Expand Up @@ -68,6 +69,7 @@ export abstract class BrowserContext extends EventEmitter {
readonly _downloads = new Set<Download>();
readonly _browser: Browser;
readonly _browserContextId: string | undefined;
_snapshotter?: Snapshotter;

constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) {
super();
Expand All @@ -83,6 +85,12 @@ export abstract class BrowserContext extends EventEmitter {
new DebugController(this);
}

// Used by test runner.
async _initSnapshotter(delegate: SnapshotterDelegate): Promise<Snapshotter> {
this._snapshotter = new Snapshotter(this, delegate);
return this._snapshotter;
}

_browserClosed() {
for (const page of this.pages())
page._didClose();
Expand Down Expand Up @@ -233,6 +241,8 @@ export abstract class BrowserContext extends EventEmitter {
this._closedStatus = 'closing';
await this._doClose();
await Promise.all([...this._downloads].map(d => d.delete()));
if (this._snapshotter)
this._snapshotter._dispose();
this._didCloseInternal();
}
await this._closePromise;
Expand Down
5 changes: 0 additions & 5 deletions src/server/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
* limitations under the License.
*/

import * as crypto from 'crypto';
import { EventEmitter } from 'events';
import * as removeFolder from 'rimraf';
import * as util from 'util';
Expand Down Expand Up @@ -67,10 +66,6 @@ class Helper {
return { width: Math.floor(size.width + 1e-3), height: Math.floor(size.height + 1e-3) };
}

static guid(): string {
return crypto.randomBytes(16).toString('hex');
}

static getViewportSizeFromWindowFeatures(features: string[]): types.Size | null {
const widthString = features.find(f => f.startsWith('width='));
const heightString = features.find(f => f.startsWith('height='));
Expand Down
2 changes: 2 additions & 0 deletions src/server/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export class Page extends EventEmitter {

async _runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> {
return runAbortableTask(async progress => {
if (this._browserContext._snapshotter)
await this._browserContext._snapshotter._doSnapshot(progress, this, 'progress');
return task(progress);
}, timeout);
}
Expand Down
7 changes: 1 addition & 6 deletions src/server/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { TimeoutError } from '../utils/errors';
import { assert } from '../utils/utils';
import { assert, monotonicTime } from '../utils/utils';
import { rewriteErrorMessage } from '../utils/stackTrace';
import { debugLogger, LogName } from '../utils/debugLogger';

Expand Down Expand Up @@ -135,9 +135,4 @@ function formatLogRecording(log: string[]): string {
return `\n${'='.repeat(leftLength)}${header}${'='.repeat(rightLength)}\n${log.join('\n')}\n${'='.repeat(headerLength)}`;
}

function monotonicTime(): number {
const [seconds, nanoseconds] = process.hrtime();
return seconds * 1000 + (nanoseconds / 1000000 | 0);
}

class AbortedError extends Error {}
240 changes: 240 additions & 0 deletions src/server/snapshotter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* 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 { BrowserContext } from './browserContext';
import { Page } from './page';
import * as network from './network';
import { helper, RegisteredListener } from './helper';
import { Progress, runAbortableTask } from './progress';
import { debugLogger } from '../utils/debugLogger';
import { Frame } from './frames';
import * as js from './javascript';
import * as types from './types';
import { SnapshotData, takeSnapshotInFrame } from './snapshotterInjected';
import { assert, calculateSha1, createGuid } from '../utils/utils';

export type SanpshotterResource = {
frameId: string,
url: string,
contentType: string,
responseHeaders: { name: string, value: string }[],
sha1: string,
};

export type SnapshotterBlob = {
buffer: Buffer,
sha1: string,
};

export type FrameSnapshot = {
frameId: string,
url: string,
html: string,
resourceOverrides: { url: string, sha1: string }[],
};
export type PageSnapshot = {
label: string,
viewportSize?: { width: number, height: number },
// First frame is the main frame.
frames: FrameSnapshot[],
};

export interface SnapshotterDelegate {
onContextCreated(context: BrowserContext): void;
onContextDestroyed(context: BrowserContext): void;
onBlob(context: BrowserContext, blob: SnapshotterBlob): void;
onResource(context: BrowserContext, resource: SanpshotterResource): void;
onSnapshot(context: BrowserContext, snapshot: PageSnapshot): void;
}

export class Snapshotter {
private _context: BrowserContext;
private _delegate: SnapshotterDelegate;
private _eventListeners: RegisteredListener[];

constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
this._context = context;
this._delegate = delegate;
this._eventListeners = [
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
this._delegate.onContextCreated(this._context);
}

async captureSnapshot(page: Page, options: types.TimeoutOptions & { label?: string } = {}): Promise<void> {
return runAbortableTask(async progress => {
await this._doSnapshot(progress, page, options.label || 'snapshot');
}, page._timeoutSettings.timeout(options));
}

_dispose() {
helper.removeEventListeners(this._eventListeners);
this._delegate.onContextDestroyed(this._context);
}

async _doSnapshot(progress: Progress, page: Page, label: string): Promise<void> {
assert(page.context() === this._context);
const snapshot = await this._snapshotPage(progress, page, label);
if (snapshot)
this._delegate.onSnapshot(this._context, snapshot);
}

private _onPage(page: Page) {
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
this._saveResource(response).catch(e => debugLogger.log('error', e));
}));
}

private async _saveResource(response: network.Response) {
const isRedirect = response.status() >= 300 && response.status() <= 399;
if (isRedirect)
return;

// Shortcut all redirects - we cannot intercept them properly.
let original = response.request();
while (original.redirectedFrom())
original = original.redirectedFrom()!;
const url = original.url();

let contentType = '';
for (const { name, value } of response.headers()) {
if (name.toLowerCase() === 'content-type')
contentType = value;
}

const body = await response.body().catch(e => debugLogger.log('error', e));
const sha1 = body ? calculateSha1(body) : 'none';
const resource: SanpshotterResource = {
frameId: response.frame()._id,
url,
contentType,
responseHeaders: response.headers(),
sha1,
};
this._delegate.onResource(this._context, resource);
if (body)
this._delegate.onBlob(this._context, { sha1, buffer: body });
}

private async _snapshotPage(progress: Progress, page: Page, label: string): Promise<PageSnapshot | null> {
const frames = page.frames();
const promises = frames.map(frame => this._snapshotFrame(progress, frame));
const results = await Promise.all(promises);

const mainFrame = results[0];
if (!mainFrame)
return null;
if (!mainFrame.snapshot.url.startsWith('http'))
mainFrame.snapshot.url = 'http://playwright.snapshot/';

const mapping = new Map<Frame, string>();
for (const result of results) {
if (!result)
continue;
for (const [key, value] of result.mapping)
mapping.set(key, value);
}

const childFrames: FrameSnapshot[] = [];
for (let i = 1; i < results.length; i++) {
const result = results[i];
if (!result)
continue;
const frame = frames[i];
if (!mapping.has(frame))
continue;
const frameSnapshot = result.snapshot;
frameSnapshot.url = mapping.get(frame)!;
childFrames.push(frameSnapshot);
}

let viewportSize = page.viewportSize();
if (!viewportSize) {
try {
if (!progress.isRunning())
return null;

const context = await page.mainFrame()._utilityContext();
viewportSize = await context.evaluateInternal(() => {
return {
width: Math.max(document.body.offsetWidth, document.documentElement.offsetWidth),
height: Math.max(document.body.offsetHeight, document.documentElement.offsetHeight),
};
});
} catch (e) {
return null;
}
}

return {
label,
viewportSize,
frames: [mainFrame.snapshot, ...childFrames],
};
}

private async _snapshotFrame(progress: Progress, frame: Frame): Promise<FrameSnapshotAndMapping | null> {
try {
if (!progress.isRunning())
return null;

const context = await frame._utilityContext();
const guid = createGuid();
const removeNoScript = !frame._page.context()._options.javaScriptEnabled;
const result = await js.evaluate(context, false /* returnByValue */, takeSnapshotInFrame, guid, removeNoScript) as js.JSHandle;
if (!progress.isRunning())
return null;

const properties = await result.getProperties();
const data = await properties.get('data')!.jsonValue() as SnapshotData;
const frameElements = await properties.get('frameElements')!.getProperties();
result.dispose();

const snapshot: FrameSnapshot = {
frameId: frame._id,
url: frame.url(),
html: data.html,
resourceOverrides: [],
};
const mapping = new Map<Frame, string>();

for (const { url, content } of data.resourceOverrides) {
const buffer = Buffer.from(content);
const sha1 = calculateSha1(buffer);
this._delegate.onBlob(this._context, { sha1, buffer });
snapshot.resourceOverrides.push({ url, sha1 });
}

for (let i = 0; i < data.frameUrls.length; i++) {
const element = frameElements.get(String(i))!.asElement();
if (!element)
continue;
const frame = await element.contentFrame().catch(e => null);
if (frame)
mapping.set(frame, data.frameUrls[i]);
}

return { snapshot, mapping };
} catch (e) {
return null;
}
}
}

type FrameSnapshotAndMapping = {
snapshot: FrameSnapshot,
mapping: Map<Frame, string>,
};
Loading