diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index b3497d321c..1cba0f0157 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -284,6 +284,10 @@ export class Frame extends ChannelOwner implements api.Fr return await this._channel.fill({ selector, value, ...options }); } + async _highlight(selector: string) { + return await this._channel.highlight({ selector }); + } + locator(selector: string, options?: { hasText?: string | RegExp }): Locator { return new Locator(this, selector, options); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index d048385ebc..232b56ca0a 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -102,6 +102,10 @@ export class Locator implements api.Locator { return this._frame.fill(this._selector, value, { strict: true, ...options }); } + async _highlight() { + return this._frame._highlight(this._selector); + } + locator(selector: string, options?: { hasText?: string | RegExp }): Locator { return new Locator(this._frame, this._selector + ' >> ' + selector, options); } diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index 3d5c6f8256..e6345adb7a 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -79,6 +79,11 @@ export class Playwright extends ChannelOwner { for (const uid of this._sockets.keys()) this._onSocksClosed(uid); }); + (global as any)._playwrightInstance = this; + } + + async _hideHighlight() { + await this._channel.hideHighlight(); } _setSelectors(selectors: Selectors) { diff --git a/packages/playwright-core/src/dispatchers/dispatcher.ts b/packages/playwright-core/src/dispatchers/dispatcher.ts index cda62fed61..b3ca1602ea 100644 --- a/packages/playwright-core/src/dispatchers/dispatcher.ts +++ b/packages/playwright-core/src/dispatchers/dispatcher.ts @@ -251,7 +251,7 @@ export class DispatcherConnection { } case 'log': { const originalMetadata = this._waitOperations.get(info.waitId)!; originalMetadata.log.push(info.message); - sdkObject.instrumentation.onCallLog('api', info.message, sdkObject, originalMetadata); + sdkObject.instrumentation.onCallLog(sdkObject, originalMetadata, 'api', info.message); this.onmessage({ id }); return; } case 'after': { diff --git a/packages/playwright-core/src/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/dispatchers/frameDispatcher.ts index e2cbd7c51d..9d94229268 100644 --- a/packages/playwright-core/src/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/frameDispatcher.ts @@ -232,6 +232,10 @@ export class FrameDispatcher extends Dispatcher im return { value: await this._frame.title() }; } + async highlight(params: channels.FrameHighlightParams, metadata: CallMetadata): Promise { + return await this._frame.highlight(params.selector); + } + async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise { const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue }); diff --git a/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts index 3b4d32c5ff..75e6f4274e 100644 --- a/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/dispatchers/playwrightDispatcher.ts @@ -82,6 +82,10 @@ export class PlaywrightDispatcher extends Dispatcher { + await this._object.hideHighlight(); + } } class SocksProxy implements SocksConnectionClient { diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index a966f6bc60..6893926cdd 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -432,6 +432,7 @@ export interface PlaywrightChannel extends PlaywrightEventTarget, Channel { socksError(params: PlaywrightSocksErrorParams, metadata?: Metadata): Promise; socksEnd(params: PlaywrightSocksEndParams, metadata?: Metadata): Promise; newRequest(params: PlaywrightNewRequestParams, metadata?: Metadata): Promise; + hideHighlight(params?: PlaywrightHideHighlightParams, metadata?: Metadata): Promise; } export type PlaywrightSocksRequestedEvent = { uid: string, @@ -530,6 +531,9 @@ export type PlaywrightNewRequestOptions = { export type PlaywrightNewRequestResult = { request: APIRequestContextChannel, }; +export type PlaywrightHideHighlightParams = {}; +export type PlaywrightHideHighlightOptions = {}; +export type PlaywrightHideHighlightResult = void; export interface PlaywrightEvents { 'socksRequested': PlaywrightSocksRequestedEvent; @@ -1779,6 +1783,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { fill(params: FrameFillParams, metadata?: Metadata): Promise; focus(params: FrameFocusParams, metadata?: Metadata): Promise; frameElement(params?: FrameFrameElementParams, metadata?: Metadata): Promise; + highlight(params: FrameHighlightParams, metadata?: Metadata): Promise; getAttribute(params: FrameGetAttributeParams, metadata?: Metadata): Promise; goto(params: FrameGotoParams, metadata?: Metadata): Promise; hover(params: FrameHoverParams, metadata?: Metadata): Promise; @@ -2028,6 +2033,13 @@ export type FrameFrameElementOptions = {}; export type FrameFrameElementResult = { element: ElementHandleChannel, }; +export type FrameHighlightParams = { + selector: string, +}; +export type FrameHighlightOptions = { + +}; +export type FrameHighlightResult = void; export type FrameGetAttributeParams = { selector: string, strict?: boolean, diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 0bd3467ea4..5c98008217 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -542,6 +542,8 @@ Playwright: returns: request: APIRequestContext + hideHighlight: + events: socksRequested: parameters: @@ -1482,6 +1484,10 @@ Frame: returns: element: ElementHandle + highlight: + parameters: + selector: string + getAttribute: parameters: selector: string diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 9bd8d2a48f..5de44413b0 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -237,6 +237,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { origins: tArray(tType('OriginStorage')), })), }); + scheme.PlaywrightHideHighlightParams = tOptional(tObject({})); scheme.SelectorsRegisterParams = tObject({ name: tString, source: tString, @@ -747,6 +748,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { timeout: tOptional(tNumber), }); scheme.FrameFrameElementParams = tOptional(tObject({})); + scheme.FrameHighlightParams = tObject({ + selector: tString, + }); scheme.FrameGetAttributeParams = tObject({ selector: tString, strict: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 590576b2c8..9514220513 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -28,7 +28,7 @@ import { Progress } from './progress'; import { Selectors } from './selectors'; import * as types from './types'; import path from 'path'; -import { CallMetadata, internalCallMetadata, createInstrumentation, SdkObject } from './instrumentation'; +import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation'; import { Debugger } from './supplements/debugger'; import { Tracing } from './trace/recorder/tracing'; import { HarRecorder } from './supplements/har/harRecorder'; @@ -76,9 +76,6 @@ export abstract class BrowserContext extends SdkObject { this._isPersistentContext = !browserContextId; this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); - // Create instrumentation per context. - this.instrumentation = createInstrumentation(); - this.fetchRequest = new BrowserContextAPIRequestContext(this); if (this._options.recordHar) @@ -104,7 +101,7 @@ export abstract class BrowserContext extends SdkObject { return; // Debugger will pause execution upon page.pause in headed mode. const contextDebugger = new Debugger(this); - this.instrumentation.addListener(contextDebugger); + this.instrumentation.addListener(contextDebugger, this); // When PWDEBUG=1, show inspector for each context. if (debugMode() === 'inspector') diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index ba7933302b..10793ed2f1 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1140,6 +1140,25 @@ export class Frame extends SdkObject { }, undefined, options); } + async highlight(selector: string) { + const pair = await this.resolveFrameForSelectorNoWait(selector); + if (!pair) + return; + const context = await this._utilityContext(); + const injectedScript = await context.injectedScript(); + return await injectedScript.evaluate((injected, { parsed }) => { + return injected.highlight(parsed); + }, { parsed: pair.info.parsed }); + } + + async hideHighlight() { + const context = await this._utilityContext(); + const injectedScript = await context.injectedScript(); + return await injectedScript.evaluate(injected => { + return injected.hideHighlight(); + }); + } + private async _elementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise { const result = await this._scheduleRerunnableTask(metadata, selector, (progress, element, data) => { const injected = progress.injectedScript; diff --git a/packages/playwright-core/src/server/injected/highlight.ts b/packages/playwright-core/src/server/injected/highlight.ts new file mode 100644 index 0000000000..8ee30c5ebe --- /dev/null +++ b/packages/playwright-core/src/server/injected/highlight.ts @@ -0,0 +1,184 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class Highlight { + private _outerGlassPaneElement: HTMLElement; + private _glassPaneShadow: ShadowRoot; + private _innerGlassPaneElement: HTMLElement; + private _highlightElements: HTMLElement[] = []; + private _tooltipElement: HTMLElement; + private _actionPointElement: HTMLElement; + private _isUnderTest: boolean; + + constructor(isUnderTest: boolean) { + this._isUnderTest = isUnderTest; + this._outerGlassPaneElement = document.createElement('x-pw-glass'); + this._outerGlassPaneElement.style.position = 'fixed'; + this._outerGlassPaneElement.style.top = '0'; + this._outerGlassPaneElement.style.right = '0'; + this._outerGlassPaneElement.style.bottom = '0'; + this._outerGlassPaneElement.style.left = '0'; + this._outerGlassPaneElement.style.zIndex = '2147483647'; + this._outerGlassPaneElement.style.pointerEvents = 'none'; + this._outerGlassPaneElement.style.display = 'flex'; + + this._tooltipElement = document.createElement('x-pw-tooltip'); + this._actionPointElement = document.createElement('x-pw-action-point'); + this._actionPointElement.setAttribute('hidden', 'true'); + + this._innerGlassPaneElement = document.createElement('x-pw-glass-inner'); + this._innerGlassPaneElement.style.flex = 'auto'; + this._innerGlassPaneElement.appendChild(this._tooltipElement); + + // Use a closed shadow root to prevent selectors matching our internal previews. + this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: this._isUnderTest ? 'open' : 'closed' }); + this._glassPaneShadow.appendChild(this._innerGlassPaneElement); + this._glassPaneShadow.appendChild(this._actionPointElement); + const styleElement = document.createElement('style'); + styleElement.textContent = ` + x-pw-tooltip { + align-items: center; + backdrop-filter: blur(5px); + background-color: rgba(0, 0, 0, 0.7); + border-radius: 2px; + box-shadow: rgba(0, 0, 0, 0.1) 0px 3.6px 3.7px, + rgba(0, 0, 0, 0.15) 0px 12.1px 12.3px, + rgba(0, 0, 0, 0.1) 0px -2px 4px, + rgba(0, 0, 0, 0.15) 0px -12.1px 24px, + rgba(0, 0, 0, 0.25) 0px 54px 55px; + color: rgb(204, 204, 204); + display: none; + font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono', + 'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace; + font-size: 12.8px; + font-weight: normal; + left: 0; + line-height: 1.5; + max-width: 600px; + padding: 3.2px 5.12px 3.2px; + position: absolute; + top: 0; + } + x-pw-action-point { + position: absolute; + width: 20px; + height: 20px; + background: red; + border-radius: 10px; + pointer-events: none; + margin: -10px 0 0 -10px; + z-index: 2; + } + *[hidden] { + display: none !important; + } + `; + this._glassPaneShadow.appendChild(styleElement); + } + + install() { + document.documentElement.appendChild(this._outerGlassPaneElement); + } + + uninstall() { + this._outerGlassPaneElement.remove(); + } + + isInstalled(): boolean { + return this._outerGlassPaneElement.parentElement === document.documentElement && !this._outerGlassPaneElement.nextElementSibling; + } + + showActionPoint(x: number, y: number) { + this._actionPointElement.style.top = y + 'px'; + this._actionPointElement.style.left = x + 'px'; + this._actionPointElement.hidden = false; + } + + hideActionPoint() { + this._actionPointElement.hidden = true; + } + + updateHighlight(elements: Element[], selector: string, isRecording: boolean) { + // Code below should trigger one layout and leave with the + // destroyed layout. + + // Destroy the layout + this._tooltipElement.textContent = selector; + this._tooltipElement.style.top = '0'; + this._tooltipElement.style.left = '0'; + this._tooltipElement.style.display = 'flex'; + + // Trigger layout. + const boxes = elements.map(e => e.getBoundingClientRect()); + const tooltipWidth = this._tooltipElement.offsetWidth; + const tooltipHeight = this._tooltipElement.offsetHeight; + const totalWidth = this._innerGlassPaneElement.offsetWidth; + const totalHeight = this._innerGlassPaneElement.offsetHeight; + + // Destroy the layout again. + if (boxes.length) { + const primaryBox = boxes[0]; + let anchorLeft = primaryBox.left; + if (anchorLeft + tooltipWidth > totalWidth - 5) + anchorLeft = totalWidth - tooltipWidth - 5; + let anchorTop = primaryBox.bottom + 5; + if (anchorTop + tooltipHeight > totalHeight - 5) { + // If can't fit below, either position above... + if (primaryBox.top > tooltipHeight + 5) { + anchorTop = primaryBox.top - tooltipHeight - 5; + } else { + // Or on top in case of large element + anchorTop = totalHeight - 5 - tooltipHeight; + } + } + this._tooltipElement.style.top = anchorTop + 'px'; + this._tooltipElement.style.left = anchorLeft + 'px'; + } else { + this._tooltipElement.style.display = 'none'; + } + + const pool = this._highlightElements; + this._highlightElements = []; + for (const box of boxes) { + const highlightElement = pool.length ? pool.shift()! : this._createHighlightElement(); + const color = isRecording ? '#dc6f6f7f' : '#6fa8dc7f'; + highlightElement.style.backgroundColor = this._highlightElements.length ? '#f6b26b7f' : color; + highlightElement.style.left = box.x + 'px'; + highlightElement.style.top = box.y + 'px'; + highlightElement.style.width = box.width + 'px'; + highlightElement.style.height = box.height + 'px'; + highlightElement.style.display = 'block'; + this._highlightElements.push(highlightElement); + } + + for (const highlightElement of pool) { + highlightElement.style.display = 'none'; + this._highlightElements.push(highlightElement); + } + } + + private _createHighlightElement(): HTMLElement { + const highlightElement = document.createElement('x-pw-highlight'); + highlightElement.style.position = 'absolute'; + highlightElement.style.top = '0'; + highlightElement.style.left = '0'; + highlightElement.style.width = '0'; + highlightElement.style.height = '0'; + highlightElement.style.boxSizing = 'border-box'; + this._glassPaneShadow.appendChild(highlightElement); + return highlightElement; + } +} diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 9c91949ed5..28417ed10a 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -23,6 +23,7 @@ import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMat import { CSSComplexSelectorList } from '../common/cssParser'; import { generateSelector } from './selectorGenerator'; import type * as channels from '../../protocol/channels'; +import { Highlight } from './highlight'; type Predicate = (progress: InjectedScriptProgress) => T | symbol; @@ -74,6 +75,7 @@ export class InjectedScript { private _browserName: string; onGlobalListenersRemoved = new Set<() => void>(); private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void); + private _highlight: Highlight | undefined; constructor(stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) { this._evaluator = new SelectorEvaluatorImpl(new Map()); @@ -856,6 +858,21 @@ export class InjectedScript { return error; } + highlight(selector: ParsedSelector) { + if (!this._highlight) { + this._highlight = new Highlight(false); + this._highlight.install(); + } + this._highlight.updateHighlight(this.querySelectorAll(selector, document.documentElement), stringifySelector(selector), false); + } + + hideHighlight() { + if (this._highlight) { + this._highlight.uninstall(); + delete this._highlight; + } + } + private _setupGlobalListenersRemovalDetection() { const customEventName = '__playwright_global_listeners_check__'; diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index aeeb13c86c..785df08d96 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -50,36 +50,42 @@ export class SdkObject extends EventEmitter { } export interface Instrumentation { - addListener(listener: InstrumentationListener): void; + addListener(listener: InstrumentationListener, context: BrowserContext | null): void; removeListener(listener: InstrumentationListener): void; onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; - onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; + onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; onEvent(sdkObject: SdkObject, metadata: CallMetadata): void; + onPageOpen(page: Page): void; + onPageClose(page: Page): void; } export interface InstrumentationListener { onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; - onCallLog?(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; + onCallLog?(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void; onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; onEvent?(sdkObject: SdkObject, metadata: CallMetadata): void; + onPageOpen?(page: Page): void; + onPageClose?(page: Page): void; } export function createInstrumentation(): Instrumentation { - const listeners: InstrumentationListener[] = []; + const listeners = new Map(); return new Proxy({}, { get: (obj: any, prop: string) => { if (prop === 'addListener') - return (listener: InstrumentationListener) => listeners.push(listener); + return (listener: InstrumentationListener, context: BrowserContext | null) => listeners.set(listener, context); if (prop === 'removeListener') - return (listener: InstrumentationListener) => listeners.splice(listeners.indexOf(listener), 1); + return (listener: InstrumentationListener) => listeners.delete(listener); if (!prop.startsWith('on')) return obj[prop]; - return async (...params: any[]) => { - for (const listener of listeners) - await (listener as any)[prop]?.(...params); + return async (sdkObject: SdkObject, ...params: any[]) => { + for (const [listener, context] of listeners) { + if (!context || sdkObject.attribution.context === context) + await (listener as any)[prop]?.(sdkObject, ...params); + } }; }, }); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 32b936af77..06ef788d0f 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -169,6 +169,7 @@ export class Page extends SdkObject { this.pdf = delegate.pdf.bind(delegate); this.coverage = delegate.coverage ? delegate.coverage() : null; this.selectors = browserContext.selectors(); + this.instrumentation.onPageOpen(this); } async initOpener(opener: PageDelegate | null) { @@ -208,6 +209,7 @@ export class Page extends SdkObject { } _didClose() { + this.instrumentation.onPageClose(this); this._frameManager.dispose(); this._frameThrottler.setEnabled(false); assert(this._closedState !== 'closed', 'Page closed twice'); @@ -217,6 +219,7 @@ export class Page extends SdkObject { } _didCrash() { + this.instrumentation.onPageClose(this); this._frameManager.dispose(); this._frameThrottler.setEnabled(false); this.emit(Page.Events.Crash); @@ -224,6 +227,7 @@ export class Page extends SdkObject { } _didDisconnect() { + this.instrumentation.onPageClose(this); this._frameManager.dispose(); this._frameThrottler.setEnabled(false); assert(!this._disconnected, 'Page disconnected twice'); @@ -518,6 +522,10 @@ export class Page extends SdkObject { const strict = typeof options?.strict === 'boolean' ? options.strict : !!this.context()._options.strictSelectors; return this.selectors.parseSelector(selector, strict); } + + async hideHighlight() { + await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {}))); + } } export class Worker extends SdkObject { diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index 7bdb6a5714..8240459fe8 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -24,6 +24,7 @@ import { Selectors } from './selectors'; import { WebKit } from './webkit/webkit'; import { CallMetadata, createInstrumentation, SdkObject } from './instrumentation'; import { debugLogger } from '../utils/debugLogger'; +import { Page } from './page'; export class Playwright extends SdkObject { readonly selectors: Selectors; @@ -33,14 +34,17 @@ export class Playwright extends SdkObject { readonly firefox: Firefox; readonly webkit: WebKit; readonly options: PlaywrightOptions; + private _allPages = new Set(); constructor(sdkLanguage: string, isInternal: boolean) { super({ attribution: { isInternal }, instrumentation: createInstrumentation() } as any, undefined, 'Playwright'); this.instrumentation.addListener({ - onCallLog: (logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata) => { + onPageOpen: page => this._allPages.add(page), + onPageClose: page => this._allPages.delete(page), + onCallLog: (sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string) => { debugLogger.log(logName as any, message); } - }); + }, null); this.options = { rootSdkObject: this, selectors: new Selectors(), @@ -53,6 +57,10 @@ export class Playwright extends SdkObject { this.android = new Android(new AdbBackend(), this.options); this.selectors = this.options.selectors; } + + async hideHighlight() { + await Promise.all([...this._allPages].map(p => p.hideHighlight().catch(() => {}))); + } } export function createPlaywright(sdkLanguage: string, isInternal: boolean = false) { diff --git a/packages/playwright-core/src/server/progress.ts b/packages/playwright-core/src/server/progress.ts index 052ad74f71..216eda0235 100644 --- a/packages/playwright-core/src/server/progress.ts +++ b/packages/playwright-core/src/server/progress.ts @@ -82,7 +82,7 @@ export class ProgressController { if (this._state === 'running') this.metadata.log.push(message); // Note: we might be sending logs after progress has finished, for example browser logs. - this.instrumentation.onCallLog(this._logName, message, this.sdkObject, this.metadata); + this.instrumentation.onCallLog(this.sdkObject, this.metadata, this._logName, message); } if ('intermediateResult' in entry) this._lastIntermediateResult = entry.intermediateResult; diff --git a/packages/playwright-core/src/server/supplements/debugger.ts b/packages/playwright-core/src/server/supplements/debugger.ts index 18ca277174..214a5679e5 100644 --- a/packages/playwright-core/src/server/supplements/debugger.ts +++ b/packages/playwright-core/src/server/supplements/debugger.ts @@ -67,7 +67,7 @@ export class Debugger extends EventEmitter implements InstrumentationListener { await this.pause(sdkObject, metadata); } - async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { + async onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): Promise { debugLogger.log(logName as any, message); } diff --git a/packages/playwright-core/src/server/supplements/injected/recorder.ts b/packages/playwright-core/src/server/supplements/injected/recorder.ts index 25891e3fd4..2ddfd0604a 100644 --- a/packages/playwright-core/src/server/supplements/injected/recorder.ts +++ b/packages/playwright-core/src/server/supplements/injected/recorder.ts @@ -19,6 +19,7 @@ import type InjectedScript from '../../injected/injectedScript'; import { generateSelector, querySelector } from '../../injected/selectorGenerator'; import type { Point } from '../../../common/types'; import type { UIState } from '../recorder/recorderTypes'; +import { Highlight } from '../../injected/highlight'; declare module globalThis { @@ -32,11 +33,6 @@ declare module globalThis { export class Recorder { private _injectedScript: InjectedScript; private _performingAction = false; - private _outerGlassPaneElement: HTMLElement; - private _glassPaneShadow: ShadowRoot; - private _innerGlassPaneElement: HTMLElement; - private _highlightElements: HTMLElement[] = []; - private _tooltipElement: HTMLElement; private _listeners: (() => void)[] = []; private _hoveredModel: HighlightModel | null = null; private _hoveredElement: HTMLElement | null = null; @@ -44,76 +40,15 @@ export class Recorder { private _expectProgrammaticKeyUp = false; private _pollRecorderModeTimer: NodeJS.Timeout | undefined; private _mode: 'none' | 'inspecting' | 'recording' = 'none'; - private _actionPointElement: HTMLElement; private _actionPoint: Point | undefined; private _actionSelector: string | undefined; private _params: { isUnderTest: boolean; }; + private _highlight: Highlight; constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean }) { this._params = params; this._injectedScript = injectedScript; - this._outerGlassPaneElement = document.createElement('x-pw-glass'); - this._outerGlassPaneElement.style.position = 'fixed'; - this._outerGlassPaneElement.style.top = '0'; - this._outerGlassPaneElement.style.right = '0'; - this._outerGlassPaneElement.style.bottom = '0'; - this._outerGlassPaneElement.style.left = '0'; - this._outerGlassPaneElement.style.zIndex = '2147483647'; - this._outerGlassPaneElement.style.pointerEvents = 'none'; - this._outerGlassPaneElement.style.display = 'flex'; - - this._tooltipElement = document.createElement('x-pw-tooltip'); - this._actionPointElement = document.createElement('x-pw-action-point'); - this._actionPointElement.setAttribute('hidden', 'true'); - - this._innerGlassPaneElement = document.createElement('x-pw-glass-inner'); - this._innerGlassPaneElement.style.flex = 'auto'; - this._innerGlassPaneElement.appendChild(this._tooltipElement); - - // Use a closed shadow root to prevent selectors matching our internal previews. - this._glassPaneShadow = this._outerGlassPaneElement.attachShadow({ mode: this._params.isUnderTest ? 'open' : 'closed' }); - this._glassPaneShadow.appendChild(this._innerGlassPaneElement); - this._glassPaneShadow.appendChild(this._actionPointElement); - const styleElement = document.createElement('style'); - styleElement.textContent = ` - x-pw-tooltip { - align-items: center; - backdrop-filter: blur(5px); - background-color: rgba(0, 0, 0, 0.7); - border-radius: 2px; - box-shadow: rgba(0, 0, 0, 0.1) 0px 3.6px 3.7px, - rgba(0, 0, 0, 0.15) 0px 12.1px 12.3px, - rgba(0, 0, 0, 0.1) 0px -2px 4px, - rgba(0, 0, 0, 0.15) 0px -12.1px 24px, - rgba(0, 0, 0, 0.25) 0px 54px 55px; - color: rgb(204, 204, 204); - display: none; - font-family: 'Dank Mono', 'Operator Mono', Inconsolata, 'Fira Mono', - 'SF Mono', Monaco, 'Droid Sans Mono', 'Source Code Pro', monospace; - font-size: 12.8px; - font-weight: normal; - left: 0; - line-height: 1.5; - max-width: 600px; - padding: 3.2px 5.12px 3.2px; - position: absolute; - top: 0; - } - x-pw-action-point { - position: absolute; - width: 20px; - height: 20px; - background: red; - border-radius: 10px; - pointer-events: none; - margin: -10px 0 0 -10px; - z-index: 2; - } - *[hidden] { - display: none !important; - } - `; - this._glassPaneShadow.appendChild(styleElement); + this._highlight = new Highlight(params.isUnderTest); this._refreshListenersIfNeeded(); injectedScript.onGlobalListenersRemoved.add(() => this._refreshListenersIfNeeded()); @@ -128,7 +63,7 @@ export class Recorder { private _refreshListenersIfNeeded() { // Ensure we are attached to the current document, and we are on top (last element); - if (this._outerGlassPaneElement.parentElement === document.documentElement && !this._outerGlassPaneElement.nextElementSibling) + if (this._highlight.isInstalled()) return; removeEventListeners(this._listeners); this._listeners = [ @@ -144,11 +79,11 @@ export class Recorder { addEventListener(document, 'focus', () => this._onFocus(), true), addEventListener(document, 'scroll', () => { this._hoveredModel = null; - this._actionPointElement.hidden = true; + this._highlight.hideActionPoint(); this._updateHighlight(); }, true), ]; - document.documentElement.appendChild(this._outerGlassPaneElement); + this._highlight.install(); } private async _pollRecorderMode() { @@ -171,13 +106,10 @@ export class Recorder { } else if (!actionPoint && !this._actionPoint) { // All good. } else { - if (actionPoint) { - this._actionPointElement.style.top = actionPoint.y + 'px'; - this._actionPointElement.style.left = actionPoint.x + 'px'; - this._actionPointElement.hidden = false; - } else { - this._actionPointElement.hidden = true; - } + if (actionPoint) + this._highlight.showActionPoint(actionPoint.x, actionPoint.y); + else + this._highlight.hideActionPoint(); this._actionPoint = actionPoint; } @@ -329,75 +261,8 @@ export class Recorder { private _updateHighlight() { const elements = this._hoveredModel ? this._hoveredModel.elements : []; - - // Code below should trigger one layout and leave with the - // destroyed layout. - - // Destroy the layout - this._tooltipElement.textContent = this._hoveredModel ? this._hoveredModel.selector : ''; - this._tooltipElement.style.top = '0'; - this._tooltipElement.style.left = '0'; - this._tooltipElement.style.display = 'flex'; - - // Trigger layout. - const boxes = elements.map(e => e.getBoundingClientRect()); - const tooltipWidth = this._tooltipElement.offsetWidth; - const tooltipHeight = this._tooltipElement.offsetHeight; - const totalWidth = this._innerGlassPaneElement.offsetWidth; - const totalHeight = this._innerGlassPaneElement.offsetHeight; - - // Destroy the layout again. - if (boxes.length) { - const primaryBox = boxes[0]; - let anchorLeft = primaryBox.left; - if (anchorLeft + tooltipWidth > totalWidth - 5) - anchorLeft = totalWidth - tooltipWidth - 5; - let anchorTop = primaryBox.bottom + 5; - if (anchorTop + tooltipHeight > totalHeight - 5) { - // If can't fit below, either position above... - if (primaryBox.top > tooltipHeight + 5) { - anchorTop = primaryBox.top - tooltipHeight - 5; - } else { - // Or on top in case of large element - anchorTop = totalHeight - 5 - tooltipHeight; - } - } - this._tooltipElement.style.top = anchorTop + 'px'; - this._tooltipElement.style.left = anchorLeft + 'px'; - } else { - this._tooltipElement.style.display = 'none'; - } - - const pool = this._highlightElements; - this._highlightElements = []; - for (const box of boxes) { - const highlightElement = pool.length ? pool.shift()! : this._createHighlightElement(); - const color = this._mode === 'recording' ? '#dc6f6f7f' : '#6fa8dc7f'; - highlightElement.style.backgroundColor = this._highlightElements.length ? '#f6b26b7f' : color; - highlightElement.style.left = box.x + 'px'; - highlightElement.style.top = box.y + 'px'; - highlightElement.style.width = box.width + 'px'; - highlightElement.style.height = box.height + 'px'; - highlightElement.style.display = 'block'; - this._highlightElements.push(highlightElement); - } - - for (const highlightElement of pool) { - highlightElement.style.display = 'none'; - this._highlightElements.push(highlightElement); - } - } - - private _createHighlightElement(): HTMLElement { - const highlightElement = document.createElement('x-pw-highlight'); - highlightElement.style.position = 'absolute'; - highlightElement.style.top = '0'; - highlightElement.style.left = '0'; - highlightElement.style.width = '0'; - highlightElement.style.height = '0'; - highlightElement.style.boxSizing = 'border-box'; - this._glassPaneShadow.appendChild(highlightElement); - return highlightElement; + const selector = this._hoveredModel ? this._hoveredModel.selector : ''; + this._highlight.updateHighlight(elements, selector, this._mode === 'recording'); } private _onInput(event: Event) { diff --git a/packages/playwright-core/src/server/supplements/recorderSupplement.ts b/packages/playwright-core/src/server/supplements/recorderSupplement.ts index d99550fe4e..3ea80780af 100644 --- a/packages/playwright-core/src/server/supplements/recorderSupplement.ts +++ b/packages/playwright-core/src/server/supplements/recorderSupplement.ts @@ -72,7 +72,7 @@ export class RecorderSupplement implements InstrumentationListener { this._contextRecorder = new ContextRecorder(context, params); this._context = context; this._debugger = Debugger.lookup(context)!; - context.instrumentation.addListener(this); + context.instrumentation.addListener(this, context); } async install() { @@ -248,7 +248,7 @@ export class RecorderSupplement implements InstrumentationListener { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { } - async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { + async onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): Promise { this.updateCallLog([metadata]); } diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index c64e517c3e..45bbc5a36f 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -131,7 +131,7 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha await fs.promises.appendFile(state.traceFile, JSON.stringify({ ...this._contextCreatedEvent, title: options.title, wallTime: Date.now() }) + '\n'); }); - this._context.instrumentation.addListener(this); + this._context.instrumentation.addListener(this, this._context); if (state.options.screenshots) this._startScreencast(); if (state.options.snapshots)