diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 0d0cfb43f2..e0d25b7e0c 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -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); } diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index 7158b0fea9..59437a333f 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -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(); private _snapshots = new Map(); 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 { + 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); } diff --git a/src/server/snapshot/snapshotServer.ts b/src/server/snapshot/snapshotServer.ts index 479f9cd1eb..69d235513c 100644 --- a/src/server/snapshot/snapshotServer.ts +++ b/src/server/snapshot/snapshotServer.ts @@ -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 { `); @@ -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; diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index bc580f8717..75f07ce80b 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -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() { diff --git a/src/server/snapshot/snapshotterInjected.ts b/src/server/snapshot/snapshotterInjected.ts index 429e527d6b..c6d4dcdd07 100644 --- a/src/server/snapshot/snapshotterInjected.ts +++ b/src/server/snapshot/snapshotterInjected.ts @@ -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_'; diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index b93e51ac65..4f9d4a5003 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -28,6 +28,7 @@ declare global { _playwrightRecorderState: () => Promise; _playwrightResume: () => Promise; _playwrightRecorderSetSelector: (selector: string) => Promise; + _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); } diff --git a/src/server/supplements/inspectorController.ts b/src/server/supplements/inspectorController.ts index aa65e89a19..cac5b82ca6 100644 --- a/src/server/supplements/inspectorController.ts +++ b/src/server/supplements/inspectorController.ts @@ -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 { 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 { diff --git a/src/server/supplements/recorder/recorderTypes.ts b/src/server/supplements/recorder/recorderTypes.ts index 6660e99522..6170e9836b 100644 --- a/src/server/supplements/recorder/recorderTypes.ts +++ b/src/server/supplements/recorder/recorderTypes.ts @@ -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 = { diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 3de8ae642f..2763ec4815 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -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(); + private _snapshotter: InMemorySnapshotter; + private _hoveredSnapshot: { callLogId: number, phase: 'before' | 'after' | 'in' } | undefined; + private _snapshots = new Set(); + private _allMetadatas = new Map(); static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[symbol] as Promise; @@ -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 { 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 { + async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { 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 { + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { 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); +} diff --git a/src/server/trace/recorder/tracer.ts b/src/server/trace/recorder/tracer.ts index 5622e60ef8..80df0d7bd0 100644 --- a/src/server/trace/recorder/tracer.ts +++ b/src/server/trace/recorder/tracer.ts @@ -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); } diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index 64377eccdc..6ff9974618 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -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//snapshotId// - // - /snapshot/pageId//timestamp// - if (parts.length !== 6 || parts[0] !== 'snapshot' || parts[1] !== 'pageId' || (parts[3] !== 'snapshotId' && parts[3] !== 'timestamp')) + // - pageId//snapshotId// + // - pageId//timestamp// + 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), }; } diff --git a/src/utils/httpServer.ts b/src/utils/httpServer.ts index 1a2d62a632..123bab554b 100644 --- a/src/utils/httpServer.ts +++ b/src/utils/httpServer.ts @@ -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 { + async start(port?: number): Promise { 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)) diff --git a/src/web/common.css b/src/web/common.css index 2f95cc4809..c18c515339 100644 --- a/src/web/common.css +++ b/src/web/common.css @@ -81,6 +81,10 @@ body { display: none !important; } +.invisible { + visibility: hidden !important; +} + svg { fill: currentColor; } diff --git a/src/web/recorder/callLog.css b/src/web/recorder/callLog.css index aebf4ee6bb..a6a36bf89d 100644 --- a/src/web/recorder/callLog.css +++ b/src/web/recorder/callLog.css @@ -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; +} diff --git a/src/web/recorder/callLog.example.ts b/src/web/recorder/callLog.example.ts index ec9c00d23f..25caff10e0 100644 --- a/src/web/recorder/callLog.example.ts +++ b/src/web/recorder/callLog.example.ts @@ -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, + } }, ]; } diff --git a/src/web/recorder/callLog.tsx b/src/web/recorder/callLog.tsx index d98fe73a83..a22b28f99f 100644 --- a/src/web/recorder/callLog.tsx +++ b/src/web/recorder/callLog.tsx @@ -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 = ({ log, + onHover, }) => { const messagesEndRef = React.createRef(); const [expandOverrides, setExpandOverrides] = React.useState>(new Map()); @@ -49,6 +51,10 @@ export const CallLogView: React.FC = ({ { callLog.params.selector ? ({callLog.params.selector}) : undefined } { typeof callLog.duration === 'number' ? — {msToString(callLog.duration)} : undefined} + {
} + onHover(callLog.id, 'before')} onMouseLeave={() => onHover(undefined)}> + onHover(callLog.id, 'in')} onMouseLeave={() => onHover(undefined)}> + onHover(callLog.id, 'after')} onMouseLeave={() => onHover(undefined)}> { (isExpanded ? callLog.messages : []).map((message, i) => { return
diff --git a/src/web/recorder/main.tsx b/src/web/recorder/main.tsx index e46e5b0b6c..f8b66f72fa 100644 --- a/src/web/recorder/main.tsx +++ b/src/web/recorder/main.tsx @@ -35,7 +35,6 @@ export const Main: React.FC = ({ const [paused, setPaused] = React.useState(false); const [log, setLog] = React.useState(new Map()); const [mode, setMode] = React.useState('none'); - const [selector, setSelector] = React.useState(''); window.playwrightSetMode = setMode; window.playwrightSetSources = setSources; diff --git a/src/web/recorder/recorder.tsx b/src/web/recorder/recorder.tsx index fb63210522..1e74073f02 100644 --- a/src/web/recorder/recorder.tsx +++ b/src/web/recorder/recorder.tsx @@ -121,7 +121,9 @@ export const Recorder: React.FC = ({ window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } }); }} /> - + { + window.dispatch({ event: 'callLogHovered', params: { callLogId, phase } }).catch(() => {}); + }}/>
; diff --git a/test/page-expose-function.spec.ts b/test/page-expose-function.spec.ts index 9b6d964042..2ec829d562 100644 --- a/test/page-expose-function.spec.ts +++ b/test/page-expose-function.spec.ts @@ -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(''); + expect(await page.evaluate('window.result')).toBe(6); +});