mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(inspector): wire snapshots to inspector (#5628)
This commit is contained in:
parent
c652794b5a
commit
aeb2b2f605
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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_';
|
||||
|
||||
@ -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);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -81,6 +81,10 @@ body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user