feat(inspector): wire snapshots to inspector (#5628)

This commit is contained in:
Pavel Feldman 2021-02-26 14:16:32 -08:00 committed by GitHub
parent c652794b5a
commit aeb2b2f605
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 259 additions and 74 deletions

View File

@ -16,7 +16,7 @@
*/
import { TimeoutSettings } from '../utils/timeoutSettings';
import { mkdirIfNeeded } from '../utils/utils';
import { isDebugMode, mkdirIfNeeded } from '../utils/utils';
import { Browser, BrowserOptions } from './browser';
import { Download } from './download';
import * as frames from './frames';
@ -396,6 +396,8 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio
throw new Error(`Browser needs to be launched with the global proxy. If all contexts override the proxy, global proxy will be never used and can be any string, for example "launch({ proxy: { server: 'per-context' } })"`);
options.proxy = normalizeProxySettings(options.proxy);
}
if (isDebugMode())
options.bypassCSP = true;
verifyGeolocation(options.geolocation);
}

View File

@ -14,10 +14,12 @@
* limitations under the License.
*/
import { HttpServer } from '../../utils/httpServer';
import { BrowserContext } from '../browserContext';
import { Page } from '../page';
import { ContextResources, FrameSnapshot } from './snapshot';
import { SnapshotRenderer } from './snapshotRenderer';
import { NetworkResponse, SnapshotStorage } from './snapshotServer';
import { NetworkResponse, SnapshotServer, SnapshotStorage } from './snapshotServer';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate, SnapshotterResource } from './snapshotter';
export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate {
@ -26,12 +28,29 @@ export class InMemorySnapshotter implements SnapshotStorage, SnapshotterDelegate
private _frameSnapshots = new Map<string, FrameSnapshot[]>();
private _snapshots = new Map<string, SnapshotRenderer>();
private _contextResources: ContextResources = new Map();
private _server: HttpServer;
private _snapshotter: Snapshotter;
constructor(context: BrowserContext) {
this._server = new HttpServer();
new SnapshotServer(this._server, this);
this._snapshotter = new Snapshotter(context, this);
}
async start(): Promise<string> {
await this._snapshotter.start();
return await this._server.start();
}
stop() {
this._snapshotter.dispose();
this._server.stop().catch(() => {});
}
async forceSnapshot(page: Page, snapshotId: string) {
await this._snapshotter.forceSnapshot(page, snapshotId);
}
onBlob(blob: SnapshotterBlob): void {
this._blobs.set(blob.sha1, blob.buffer);
}

View File

@ -33,32 +33,17 @@ export interface SnapshotStorage {
}
export class SnapshotServer {
private _urlPrefix: string;
private _snapshotStorage: SnapshotStorage;
constructor(server: HttpServer, snapshotStorage: SnapshotStorage) {
this._urlPrefix = server.urlPrefix();
this._snapshotStorage = snapshotStorage;
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this), true);
server.routePath('/snapshot/', this._serveSnapshotRoot.bind(this));
server.routePath('/snapshot/service-worker.js', this._serveServiceWorker.bind(this));
server.routePath('/snapshot-data', this._serveSnapshot.bind(this));
server.routePrefix('/resources/', this._serveResource.bind(this));
}
snapshotRootUrl() {
return this._urlPrefix + '/snapshot/';
}
snapshotUrl(pageId: string, snapshotId?: string, timestamp?: number) {
// Prefer snapshotId over timestamp.
if (snapshotId)
return this._urlPrefix + `/snapshot/pageId/${pageId}/snapshotId/${snapshotId}/main`;
if (timestamp)
return this._urlPrefix + `/snapshot/pageId/${pageId}/timestamp/${timestamp}/main`;
return 'data:text/html,Snapshot is not available';
}
private _serveSnapshotRoot(request: http.IncomingMessage, response: http.ServerResponse): boolean {
response.statusCode = 200;
response.setHeader('Cache-Control', 'public, max-age=31536000');
@ -80,8 +65,8 @@ export class SnapshotServer {
</style>
<body>
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('./service-worker.js');
let showPromise = Promise.resolve();
if (!navigator.serviceWorker.controller)
showPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
@ -105,6 +90,10 @@ export class SnapshotServer {
await showPromise;
next.src = url;
};
window.addEventListener('message', event => {
window.showSnapshot(window.location.href + event.data.snapshotId);
}, false);
}
</script>
</body>
`);
@ -148,13 +137,13 @@ export class SnapshotServer {
let snapshotId: string;
if (request.mode === 'navigate') {
snapshotId = pathname;
snapshotId = pathname.substring('/snapshot/'.length);
} else {
const client = (await self.clients.get(event.clientId))!;
snapshotId = new URL(client.url).pathname;
snapshotId = new URL(client.url).pathname.substring('/snapshot/'.length);
}
if (request.mode === 'navigate') {
const htmlResponse = await fetch(`/snapshot-data?snapshotName=${snapshotId}`);
const htmlResponse = await fetch(`/snapshot-data?snapshotId=${snapshotId}`);
const { html, resources }: RenderedFrameSnapshot = await htmlResponse.json();
if (!html)
return respondNotAvailable();
@ -208,7 +197,7 @@ export class SnapshotServer {
response.setHeader('Cache-Control', 'public, max-age=31536000');
response.setHeader('Content-Type', 'application/json');
const parsed: any = querystring.parse(request.url!.substring(request.url!.indexOf('?') + 1));
const snapshot = this._snapshotStorage.snapshotById(parsed.snapshotName);
const snapshot = this._snapshotStorage.snapshotById(parsed.snapshotId);
const snapshotData: any = snapshot ? snapshot.render() : { html: '' };
response.end(JSON.stringify(snapshotData));
return true;

View File

@ -52,15 +52,18 @@ export interface SnapshotterDelegate {
export class Snapshotter {
private _context: BrowserContext;
private _delegate: SnapshotterDelegate;
private _eventListeners: RegisteredListener[];
private _eventListeners: RegisteredListener[] = [];
constructor(context: BrowserContext, delegate: SnapshotterDelegate) {
this._context = context;
this._delegate = delegate;
}
async start() {
this._eventListeners = [
helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
await this._context.exposeBinding(kSnapshotBinding, false, (source, data: SnapshotData) => {
const snapshot: FrameSnapshot = {
snapshotId: data.snapshotId,
pageId: source.page.idInSnapshot,
@ -83,7 +86,10 @@ export class Snapshotter {
}
this._delegate.onFrameSnapshot(snapshot);
});
this._context._doAddInitScript('(' + frameSnapshotStreamer.toString() + ')()');
const initScript = '(' + frameSnapshotStreamer.toString() + ')()';
await this._context._doAddInitScript(initScript);
for (const page of this._context.pages())
await page.mainFrame()._evaluateExpression(initScript, false, undefined, 'main');
}
dispose() {

View File

@ -37,6 +37,9 @@ export function frameSnapshotStreamer() {
const kSnapshotStreamer = '__playwright_snapshot_streamer_';
const kSnapshotBinding = '__playwright_snapshot_binding_';
if ((window as any)[kSnapshotStreamer])
return;
// Attributes present in the snapshot.
const kShadowAttribute = '__playwright_shadow_root_';
const kScrollTopAttribute = '__playwright_scroll_top_';

View File

@ -28,6 +28,7 @@ declare global {
_playwrightRecorderState: () => Promise<UIState>;
_playwrightResume: () => Promise<void>;
_playwrightRecorderSetSelector: (selector: string) => Promise<void>;
_playwrightRefreshOverlay: () => void;
}
}
@ -52,8 +53,11 @@ export class Recorder {
private _actionPoint: Point | undefined;
private _actionSelector: string | undefined;
private _params: { isUnderTest: boolean; };
private _snapshotIframe: HTMLIFrameElement | undefined;
private _snapshotId: string | undefined;
private _snapshotBaseUrl: string;
constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean }) {
constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean, snapshotBaseUrl: string }) {
this._params = params;
this._injectedScript = injectedScript;
this._outerGlassPaneElement = document.createElement('x-pw-glass');
@ -65,6 +69,7 @@ export class Recorder {
this._outerGlassPaneElement.style.zIndex = '2147483647';
this._outerGlassPaneElement.style.pointerEvents = 'none';
this._outerGlassPaneElement.style.display = 'flex';
this._snapshotBaseUrl = params.snapshotBaseUrl;
this._tooltipElement = document.createElement('x-pw-tooltip');
this._actionPointElement = document.createElement('x-pw-action-point');
@ -122,10 +127,15 @@ export class Recorder {
this._refreshListenersIfNeeded();
setInterval(() => {
this._refreshListenersIfNeeded();
if ((window as any)._recorderScriptReadyForTest)
if ((window as any)._recorderScriptReadyForTest) {
(window as any)._recorderScriptReadyForTest();
delete (window as any)._recorderScriptReadyForTest;
}
}, 500);
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
window._playwrightRefreshOverlay = () => {
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
};
window._playwrightRefreshOverlay();
}
private _refreshListenersIfNeeded() {
@ -152,8 +162,29 @@ export class Recorder {
document.documentElement.appendChild(this._outerGlassPaneElement);
}
private _createSnapshotIframeIfNeeded(): HTMLIFrameElement | undefined {
if (this._snapshotIframe)
return this._snapshotIframe;
if (window.top === window) {
this._snapshotIframe = document.createElement('iframe');
this._snapshotIframe.src = this._snapshotBaseUrl;
this._snapshotIframe.style.position = 'fixed';
this._snapshotIframe.style.top = '0';
this._snapshotIframe.style.right = '0';
this._snapshotIframe.style.bottom = '0';
this._snapshotIframe.style.left = '0';
this._snapshotIframe.style.border = 'none';
this._snapshotIframe.style.width = '100%';
this._snapshotIframe.style.height = '100%';
this._snapshotIframe.style.zIndex = '2147483647';
this._snapshotIframe.style.visibility = 'hidden';
document.documentElement.appendChild(this._snapshotIframe);
}
return this._snapshotIframe;
}
private async _pollRecorderMode() {
const pollPeriod = 250;
const pollPeriod = 1000;
if (this._pollRecorderModeTimer)
clearTimeout(this._pollRecorderModeTimer);
const state = await window._playwrightRecorderState().catch(e => null);
@ -162,7 +193,7 @@ export class Recorder {
return;
}
const { mode, actionPoint, actionSelector } = state;
const { mode, actionPoint, actionSelector, snapshotId } = state;
if (mode !== this._mode) {
this._mode = mode;
this._clearHighlight();
@ -191,6 +222,18 @@ export class Recorder {
this._updateHighlight();
this._actionSelector = actionSelector;
}
if (snapshotId !== this._snapshotId) {
this._snapshotId = snapshotId;
const snapshotIframe = this._createSnapshotIframeIfNeeded();
if (snapshotIframe) {
if (!snapshotId) {
snapshotIframe.style.visibility = 'hidden';
} else {
snapshotIframe.style.visibility = 'visible';
snapshotIframe.contentWindow?.postMessage({ snapshotId }, '*');
}
}
}
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
}

View File

@ -83,14 +83,14 @@ export class InspectorController implements InstrumentationListener {
}
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
await recorder?.onAfterCall(metadata);
await recorder?.onAfterCall(sdkObject, metadata);
}
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.context)
return;
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
await recorder?.onBeforeInputAction(metadata);
await recorder?.onBeforeInputAction(sdkObject, metadata);
}
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {

View File

@ -19,7 +19,7 @@ import { Point } from '../../../common/types';
export type Mode = 'inspecting' | 'recording' | 'none';
export type EventData = {
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated';
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated' | 'callLogHovered';
params: any;
};
@ -27,6 +27,7 @@ export type UIState = {
mode: Mode;
actionPoint?: Point;
actionSelector?: string;
snapshotId?: string;
};
export type CallLog = {
@ -41,6 +42,11 @@ export type CallLog = {
url?: string,
selector?: string,
};
snapshots: {
before: boolean,
in: boolean,
after: boolean,
}
};
export type SourceHighlight = {

View File

@ -32,6 +32,7 @@ import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentatio
import { Point } from '../../common/types';
import { CallLog, EventData, Mode, Source, UIState } from './recorder/recorderTypes';
import { isUnderTest, monotonicTime } from '../../utils/utils';
import { InMemorySnapshotter } from '../snapshot/inMemorySnapshotter';
type BindingSource = { frame: Frame, page: Page };
@ -53,6 +54,10 @@ export class RecorderSupplement {
private _pauseOnNextStatement: boolean;
private _recorderSources: Source[];
private _userSources = new Map<string, Source>();
private _snapshotter: InMemorySnapshotter;
private _hoveredSnapshot: { callLogId: number, phase: 'before' | 'after' | 'in' } | undefined;
private _snapshots = new Set<string>();
private _allMetadatas = new Map<number, CallMetadata>();
static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<RecorderSupplement> {
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
@ -119,21 +124,32 @@ export class RecorderSupplement {
});
}
this._generator = generator;
this._snapshotter = new InMemorySnapshotter(context);
}
async install() {
const recorderApp = await RecorderApp.open(this._context);
this._recorderApp = recorderApp;
recorderApp.once('close', () => {
this._snapshotter.stop();
this._recorderApp = null;
});
recorderApp.on('event', (data: EventData) => {
if (data.event === 'setMode') {
this._setMode(data.params.mode);
this._refreshOverlay();
return;
}
if (data.event === 'selectorUpdated') {
this._highlightedSelector = data.params.selector;
this._refreshOverlay();
return;
}
if (data.event === 'callLogHovered') {
this._hoveredSnapshot = undefined;
if (this._isPaused())
this._hoveredSnapshot = data.params;
this._refreshOverlay();
return;
}
if (data.event === 'step') {
@ -185,15 +201,27 @@ export class RecorderSupplement {
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
await this._context.exposeBinding('_playwrightRecorderState', false, source => {
let actionPoint: Point | undefined = undefined;
let actionSelector: string | undefined = undefined;
for (const [metadata, sdkObject] of this._currentCallsMetadata) {
if (source.page === sdkObject.attribution.page) {
actionPoint = metadata.point || actionPoint;
actionSelector = metadata.params.selector || actionSelector;
let snapshotId: string | undefined;
let actionSelector: string | undefined;
let actionPoint: Point | undefined;
if (this._hoveredSnapshot) {
snapshotId = this._hoveredSnapshot.phase + '@' + this._hoveredSnapshot.callLogId;
const metadata = this._allMetadatas.get(this._hoveredSnapshot.callLogId);
actionPoint = this._hoveredSnapshot.phase === 'in' ? metadata?.point : undefined;
} else {
for (const [metadata, sdkObject] of this._currentCallsMetadata) {
if (source.page === sdkObject.attribution.page) {
actionPoint = metadata.point || actionPoint;
actionSelector = metadata.params.selector || actionSelector;
}
}
}
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector: this._highlightedSelector || actionSelector };
const uiState: UIState = {
mode: this._mode,
actionPoint,
actionSelector,
snapshotId,
};
return uiState;
});
@ -207,9 +235,9 @@ export class RecorderSupplement {
this._resume(false).catch(() => {});
});
await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest() });
const snapshotBaseUrl = await this._snapshotter.start() + '/snapshot/';
await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest(), snapshotBaseUrl });
await this._context.extendInjectedScript(consoleApiSource.source);
(this._context as any).recorderAppForTest = recorderApp;
}
@ -224,6 +252,10 @@ export class RecorderSupplement {
return result;
}
_isPaused(): boolean {
return !!this._pausedCallsMetadata.size;
}
private _setMode(mode: Mode) {
this._mode = mode;
this._recorderApp?.setMode(this._mode);
@ -247,6 +279,11 @@ export class RecorderSupplement {
this.updateCallLog([...this._currentCallsMetadata.keys()]);
}
private _refreshOverlay() {
for (const page of this._context.pages())
page.mainFrame()._evaluateExpression('window._playwrightRefreshOverlay', false, undefined, 'main').catch(() => {});
}
private async _onPage(page: Page) {
// First page is called page, others are called popup1, popup2, etc.
const frame = page.mainFrame();
@ -362,10 +399,20 @@ 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') {
if (sdkObject.attribution.page) {
const snapshotId = `${phase}@${metadata.id}`;
this._snapshots.add(snapshotId);
await this._snapshotter.forceSnapshot(sdkObject.attribution.page, snapshotId);
}
}
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._mode === 'recording')
return;
await this._captureSnapshot(sdkObject, metadata, 'before');
this._currentCallsMetadata.set(metadata, sdkObject);
this._allMetadatas.set(metadata.id, metadata);
this._updateUserSources();
this.updateCallLog([metadata]);
if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata)))
@ -376,9 +423,10 @@ export class RecorderSupplement {
}
}
async onAfterCall(metadata: CallMetadata): Promise<void> {
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._mode === 'recording')
return;
await this._captureSnapshot(sdkObject, metadata, 'after');
if (!metadata.error)
this._currentCallsMetadata.delete(metadata);
this._pausedCallsMetadata.delete(metadata);
@ -420,9 +468,10 @@ export class RecorderSupplement {
this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]);
}
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (this._mode === 'recording')
return;
await this._captureSnapshot(sdkObject, metadata, 'in');
if (this._pauseOnNextStatement)
await this.pause(metadata);
}
@ -458,7 +507,12 @@ export class RecorderSupplement {
status,
error: metadata.error,
params,
duration
duration,
snapshots: {
before: showBeforeSnapshot(metadata) && this._snapshots.has(`before@${metadata.id}`),
in: showInSnapshot(metadata) && this._snapshots.has(`in@${metadata.id}`),
after: showAfterSnapshot(metadata) && this._snapshots.has(`after@${metadata.id}`),
}
});
}
this._recorderApp?.updateCallLogs(logs);
@ -492,3 +546,15 @@ function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolea
function shouldPauseOnStep(sdkObject: SdkObject, metadata: CallMetadata): boolean {
return metadata.method === 'goto' || metadata.method === 'close';
}
function showBeforeSnapshot(metadata: CallMetadata): boolean {
return metadata.method === 'close';
}
function showInSnapshot(metadata: CallMetadata): boolean {
return ['click', 'dblclick', 'check', 'uncheck', 'fill', 'press'].includes(metadata.method);
}
function showAfterSnapshot(metadata: CallMetadata): boolean {
return ['goto', 'click', 'dblclick', 'dblclick', 'check', 'uncheck', 'fill', 'press'].includes(metadata.method);
}

View File

@ -44,6 +44,7 @@ export class Tracer implements InstrumentationListener {
const traceStorageDir = path.join(traceDir, 'resources');
const tracePath = path.join(traceDir, createGuid() + '.trace');
const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
await contextTracer.start();
this._contextTracers.set(context, contextTracer);
}
@ -110,6 +111,10 @@ class ContextTracer implements SnapshotterDelegate {
];
}
async start() {
await this._snapshotter.start();
}
onBlob(blob: SnapshotterBlob): void {
this._writeArtifact(blob.sha1, blob.buffer);
}

View File

@ -86,7 +86,7 @@ class TraceViewer implements SnapshotStorage {
const absolutePath = path.join(__dirname, '..', '..', '..', 'web', ...relativePath.split('/'));
return server.serveFile(response, absolutePath);
};
server.routePrefix('/traceviewer/', traceViewerHandler, true);
server.routePrefix('/traceviewer/', traceViewerHandler);
const fileHandler: ServerRouteHandler = (request, response) => {
try {
@ -122,9 +122,9 @@ class TraceViewer implements SnapshotStorage {
return traceModel.resourceById.get(resourceId)!;
}
snapshotById(snapshotName: string): SnapshotRenderer | undefined {
snapshotById(snapshotId: string): SnapshotRenderer | undefined {
const traceModel = this._document!.model;
const parsed = parseSnapshotName(snapshotName);
const parsed = parseSnapshotName(snapshotId);
const snapshot = parsed.snapshotId ? traceModel.findSnapshotById(parsed.pageId, parsed.frameId, parsed.snapshotId) : traceModel.findSnapshotByTime(parsed.pageId, parsed.frameId, parsed.timestamp!);
return snapshot;
}
@ -143,18 +143,14 @@ export async function showTraceViewer(traceDir: string) {
function parseSnapshotName(pathname: string): { pageId: string, frameId: string, timestamp?: number, snapshotId?: string } {
const parts = pathname.split('/');
if (!parts[0])
parts.shift();
if (!parts[parts.length - 1])
parts.pop();
// - /snapshot/pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
// - /snapshot/pageId/<pageId>/timestamp/<timestamp>/<frameId>
if (parts.length !== 6 || parts[0] !== 'snapshot' || parts[1] !== 'pageId' || (parts[3] !== 'snapshotId' && parts[3] !== 'timestamp'))
// - pageId/<pageId>/snapshotId/<snapshotId>/<frameId>
// - pageId/<pageId>/timestamp/<timestamp>/<frameId>
if (parts.length !== 5 || parts[0] !== 'pageId' || (parts[2] !== 'snapshotId' && parts[2] !== 'timestamp'))
throw new Error(`Unexpected path "${pathname}"`);
return {
pageId: parts[2],
frameId: parts[5] === 'main' ? parts[2] : parts[5],
snapshotId: (parts[3] === 'snapshotId' ? parts[4] : undefined),
timestamp: (parts[3] === 'timestamp' ? +parts[4] : undefined),
pageId: parts[1],
frameId: parts[4] === 'main' ? parts[1] : parts[4],
snapshotId: (parts[2] === 'snapshotId' ? parts[3] : undefined),
timestamp: (parts[2] === 'timestamp' ? +parts[3] : undefined),
};
}

View File

@ -23,23 +23,23 @@ export type ServerRouteHandler = (request: http.IncomingMessage, response: http.
export class HttpServer {
private _server: http.Server | undefined;
private _urlPrefix: string;
private _routes: { prefix?: string, exact?: string, needsReferrer: boolean, handler: ServerRouteHandler }[] = [];
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
constructor() {
this._urlPrefix = '';
}
routePrefix(prefix: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
this._routes.push({ prefix, handler, needsReferrer: !skipReferrerCheck });
routePrefix(prefix: string, handler: ServerRouteHandler) {
this._routes.push({ prefix, handler });
}
routePath(path: string, handler: ServerRouteHandler, skipReferrerCheck?: boolean) {
this._routes.push({ exact: path, handler, needsReferrer: !skipReferrerCheck });
routePath(path: string, handler: ServerRouteHandler) {
this._routes.push({ exact: path, handler });
}
async start(): Promise<string> {
async start(port?: number): Promise<string> {
this._server = http.createServer(this._onRequest.bind(this));
this._server.listen();
this._server.listen(port);
await new Promise(cb => this._server!.once('listening', cb));
const address = this._server.address();
this._urlPrefix = typeof address === 'string' ? address : `http://127.0.0.1:${address.port}`;
@ -78,10 +78,7 @@ export class HttpServer {
return;
}
const url = new URL('http://localhost' + request.url);
const hasReferrer = request.headers['referer'] && request.headers['referer'].startsWith(this._urlPrefix);
for (const route of this._routes) {
if (route.needsReferrer && !hasReferrer)
continue;
if (route.exact && url.pathname === route.exact && route.handler(request, response))
return;
if (route.prefix && url.pathname.startsWith(route.prefix) && route.handler(request, response))

View File

@ -81,6 +81,10 @@ body {
display: none !important;
}
.invisible {
visibility: hidden !important;
}
svg {
fill: currentColor;
}

View File

@ -78,3 +78,17 @@
margin-left: 4px;
color: var(--gray);
}
.call-log-call .codicon.preview {
visibility: hidden;
color: var(--toolbar-color);
cursor: pointer;
}
.call-log-call .codicon.preview:hover {
color: inherit;
}
.call-log-call:hover .codicon.preview {
visibility: visible;
}

View File

@ -24,7 +24,12 @@ export function exampleCallLog(): CallLog[] {
'title': 'newPage',
'status': 'done',
'duration': 100,
'params': {}
'params': {},
'snapshots': {
'before': true,
'in': false,
'after': true,
}
},
{
'id': 4,
@ -36,6 +41,11 @@ export function exampleCallLog(): CallLog[] {
'params': {
'url': 'https://github.com/microsoft'
},
'snapshots': {
'before': true,
'in': false,
'after': true,
},
'duration': 1100,
},
{
@ -57,6 +67,11 @@ export function exampleCallLog(): CallLog[] {
'params': {
'selector': 'input[aria-label="Find a repository…"]'
},
'snapshots': {
'before': true,
'in': true,
'after': false,
}
},
{
'id': 6,
@ -68,6 +83,11 @@ export function exampleCallLog(): CallLog[] {
'status': 'error',
'params': {
},
'snapshots': {
'before': false,
'in': false,
'after': false,
}
},
];
}

View File

@ -20,11 +20,13 @@ import type { CallLog } from '../../server/supplements/recorder/recorderTypes';
import { msToString } from '../uiUtils';
export interface CallLogProps {
log: CallLog[]
log: CallLog[],
onHover: (callLogId: number | undefined, phase?: 'before' | 'after' | 'in') => void
}
export const CallLogView: React.FC<CallLogProps> = ({
log,
onHover,
}) => {
const messagesEndRef = React.createRef<HTMLDivElement>();
const [expandOverrides, setExpandOverrides] = React.useState<Map<number, boolean>>(new Map());
@ -49,6 +51,10 @@ export const CallLogView: React.FC<CallLogProps> = ({
{ callLog.params.selector ? <span>(<span className='call-log-selector'>{callLog.params.selector}</span>)</span> : undefined }
<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>
</div>
{ (isExpanded ? callLog.messages : []).map((message, i) => {
return <div className='call-log-message' key={i}>

View File

@ -35,7 +35,6 @@ export const Main: React.FC = ({
const [paused, setPaused] = React.useState(false);
const [log, setLog] = React.useState(new Map<number, CallLog>());
const [mode, setMode] = React.useState<Mode>('none');
const [selector, setSelector] = React.useState('');
window.playwrightSetMode = setMode;
window.playwrightSetSources = setSources;

View File

@ -121,7 +121,9 @@ export const Recorder: React.FC<RecorderProps> = ({
window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
}} />
</Toolbar>
<CallLogView log={[...log.values()]}/>
<CallLogView log={Array.from(log.values())} onHover={(callLogId, phase) => {
window.dispatch({ event: 'callLogHovered', params: { callLogId, phase } }).catch(() => {});
}}/>
</div>
</SplitView>
</div>;

View File

@ -281,3 +281,11 @@ it('exposeBinding(handle) should work with element handles', async ({ page}) =>
await page.click('#a1');
expect(await promise).toBe('Click me');
});
it('should work with setContent', async ({page, server}) => {
await page.exposeFunction('compute', function(a, b) {
return Promise.resolve(a * b);
});
await page.setContent('<script>window.result = compute(3, 2)</script>');
expect(await page.evaluate('window.result')).toBe(6);
});