chore: implement locator._highlight / playwright._hideHighlight (#11339)

This commit is contained in:
Pavel Feldman 2022-01-12 07:37:48 -08:00 committed by GitHub
parent bd837b5863
commit a12e76b52b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 316 additions and 169 deletions

View file

@ -284,6 +284,10 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
return await this._channel.fill({ selector, value, ...options }); 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 { locator(selector: string, options?: { hasText?: string | RegExp }): Locator {
return new Locator(this, selector, options); return new Locator(this, selector, options);
} }

View file

@ -102,6 +102,10 @@ export class Locator implements api.Locator {
return this._frame.fill(this._selector, value, { strict: true, ...options }); 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 { locator(selector: string, options?: { hasText?: string | RegExp }): Locator {
return new Locator(this._frame, this._selector + ' >> ' + selector, options); return new Locator(this._frame, this._selector + ' >> ' + selector, options);
} }

View file

@ -79,6 +79,11 @@ export class Playwright extends ChannelOwner<channels.PlaywrightChannel> {
for (const uid of this._sockets.keys()) for (const uid of this._sockets.keys())
this._onSocksClosed(uid); this._onSocksClosed(uid);
}); });
(global as any)._playwrightInstance = this;
}
async _hideHighlight() {
await this._channel.hideHighlight();
} }
_setSelectors(selectors: Selectors) { _setSelectors(selectors: Selectors) {

View file

@ -251,7 +251,7 @@ export class DispatcherConnection {
} case 'log': { } case 'log': {
const originalMetadata = this._waitOperations.get(info.waitId)!; const originalMetadata = this._waitOperations.get(info.waitId)!;
originalMetadata.log.push(info.message); originalMetadata.log.push(info.message);
sdkObject.instrumentation.onCallLog('api', info.message, sdkObject, originalMetadata); sdkObject.instrumentation.onCallLog(sdkObject, originalMetadata, 'api', info.message);
this.onmessage({ id }); this.onmessage({ id });
return; return;
} case 'after': { } case 'after': {

View file

@ -232,6 +232,10 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel> im
return { value: await this._frame.title() }; return { value: await this._frame.title() };
} }
async highlight(params: channels.FrameHighlightParams, metadata: CallMetadata): Promise<void> {
return await this._frame.highlight(params.selector);
}
async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise<channels.FrameExpectResult> { async expect(params: channels.FrameExpectParams, metadata: CallMetadata): Promise<channels.FrameExpectResult> {
const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined; const expectedValue = params.expectedValue ? parseArgument(params.expectedValue) : undefined;
const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue }); const result = await this._frame.expect(metadata, params.selector, { ...params, expectedValue });

View file

@ -82,6 +82,10 @@ export class PlaywrightDispatcher extends Dispatcher<Playwright, channels.Playwr
const request = new GlobalAPIRequestContext(this._object, params); const request = new GlobalAPIRequestContext(this._object, params);
return { request: APIRequestContextDispatcher.from(this._scope, request) }; return { request: APIRequestContextDispatcher.from(this._scope, request) };
} }
async hideHighlight(params: channels.PlaywrightHideHighlightParams, metadata?: channels.Metadata): Promise<channels.PlaywrightHideHighlightResult> {
await this._object.hideHighlight();
}
} }
class SocksProxy implements SocksConnectionClient { class SocksProxy implements SocksConnectionClient {

View file

@ -432,6 +432,7 @@ export interface PlaywrightChannel extends PlaywrightEventTarget, Channel {
socksError(params: PlaywrightSocksErrorParams, metadata?: Metadata): Promise<PlaywrightSocksErrorResult>; socksError(params: PlaywrightSocksErrorParams, metadata?: Metadata): Promise<PlaywrightSocksErrorResult>;
socksEnd(params: PlaywrightSocksEndParams, metadata?: Metadata): Promise<PlaywrightSocksEndResult>; socksEnd(params: PlaywrightSocksEndParams, metadata?: Metadata): Promise<PlaywrightSocksEndResult>;
newRequest(params: PlaywrightNewRequestParams, metadata?: Metadata): Promise<PlaywrightNewRequestResult>; newRequest(params: PlaywrightNewRequestParams, metadata?: Metadata): Promise<PlaywrightNewRequestResult>;
hideHighlight(params?: PlaywrightHideHighlightParams, metadata?: Metadata): Promise<PlaywrightHideHighlightResult>;
} }
export type PlaywrightSocksRequestedEvent = { export type PlaywrightSocksRequestedEvent = {
uid: string, uid: string,
@ -530,6 +531,9 @@ export type PlaywrightNewRequestOptions = {
export type PlaywrightNewRequestResult = { export type PlaywrightNewRequestResult = {
request: APIRequestContextChannel, request: APIRequestContextChannel,
}; };
export type PlaywrightHideHighlightParams = {};
export type PlaywrightHideHighlightOptions = {};
export type PlaywrightHideHighlightResult = void;
export interface PlaywrightEvents { export interface PlaywrightEvents {
'socksRequested': PlaywrightSocksRequestedEvent; 'socksRequested': PlaywrightSocksRequestedEvent;
@ -1779,6 +1783,7 @@ export interface FrameChannel extends FrameEventTarget, Channel {
fill(params: FrameFillParams, metadata?: Metadata): Promise<FrameFillResult>; fill(params: FrameFillParams, metadata?: Metadata): Promise<FrameFillResult>;
focus(params: FrameFocusParams, metadata?: Metadata): Promise<FrameFocusResult>; focus(params: FrameFocusParams, metadata?: Metadata): Promise<FrameFocusResult>;
frameElement(params?: FrameFrameElementParams, metadata?: Metadata): Promise<FrameFrameElementResult>; frameElement(params?: FrameFrameElementParams, metadata?: Metadata): Promise<FrameFrameElementResult>;
highlight(params: FrameHighlightParams, metadata?: Metadata): Promise<FrameHighlightResult>;
getAttribute(params: FrameGetAttributeParams, metadata?: Metadata): Promise<FrameGetAttributeResult>; getAttribute(params: FrameGetAttributeParams, metadata?: Metadata): Promise<FrameGetAttributeResult>;
goto(params: FrameGotoParams, metadata?: Metadata): Promise<FrameGotoResult>; goto(params: FrameGotoParams, metadata?: Metadata): Promise<FrameGotoResult>;
hover(params: FrameHoverParams, metadata?: Metadata): Promise<FrameHoverResult>; hover(params: FrameHoverParams, metadata?: Metadata): Promise<FrameHoverResult>;
@ -2028,6 +2033,13 @@ export type FrameFrameElementOptions = {};
export type FrameFrameElementResult = { export type FrameFrameElementResult = {
element: ElementHandleChannel, element: ElementHandleChannel,
}; };
export type FrameHighlightParams = {
selector: string,
};
export type FrameHighlightOptions = {
};
export type FrameHighlightResult = void;
export type FrameGetAttributeParams = { export type FrameGetAttributeParams = {
selector: string, selector: string,
strict?: boolean, strict?: boolean,

View file

@ -542,6 +542,8 @@ Playwright:
returns: returns:
request: APIRequestContext request: APIRequestContext
hideHighlight:
events: events:
socksRequested: socksRequested:
parameters: parameters:
@ -1482,6 +1484,10 @@ Frame:
returns: returns:
element: ElementHandle element: ElementHandle
highlight:
parameters:
selector: string
getAttribute: getAttribute:
parameters: parameters:
selector: string selector: string

View file

@ -237,6 +237,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
origins: tArray(tType('OriginStorage')), origins: tArray(tType('OriginStorage')),
})), })),
}); });
scheme.PlaywrightHideHighlightParams = tOptional(tObject({}));
scheme.SelectorsRegisterParams = tObject({ scheme.SelectorsRegisterParams = tObject({
name: tString, name: tString,
source: tString, source: tString,
@ -747,6 +748,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
timeout: tOptional(tNumber), timeout: tOptional(tNumber),
}); });
scheme.FrameFrameElementParams = tOptional(tObject({})); scheme.FrameFrameElementParams = tOptional(tObject({}));
scheme.FrameHighlightParams = tObject({
selector: tString,
});
scheme.FrameGetAttributeParams = tObject({ scheme.FrameGetAttributeParams = tObject({
selector: tString, selector: tString,
strict: tOptional(tBoolean), strict: tOptional(tBoolean),

View file

@ -28,7 +28,7 @@ import { Progress } from './progress';
import { Selectors } from './selectors'; import { Selectors } from './selectors';
import * as types from './types'; import * as types from './types';
import path from 'path'; import path from 'path';
import { CallMetadata, internalCallMetadata, createInstrumentation, SdkObject } from './instrumentation'; import { CallMetadata, internalCallMetadata, SdkObject } from './instrumentation';
import { Debugger } from './supplements/debugger'; import { Debugger } from './supplements/debugger';
import { Tracing } from './trace/recorder/tracing'; import { Tracing } from './trace/recorder/tracing';
import { HarRecorder } from './supplements/har/harRecorder'; import { HarRecorder } from './supplements/har/harRecorder';
@ -76,9 +76,6 @@ export abstract class BrowserContext extends SdkObject {
this._isPersistentContext = !browserContextId; this._isPersistentContext = !browserContextId;
this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill);
// Create instrumentation per context.
this.instrumentation = createInstrumentation();
this.fetchRequest = new BrowserContextAPIRequestContext(this); this.fetchRequest = new BrowserContextAPIRequestContext(this);
if (this._options.recordHar) if (this._options.recordHar)
@ -104,7 +101,7 @@ export abstract class BrowserContext extends SdkObject {
return; return;
// Debugger will pause execution upon page.pause in headed mode. // Debugger will pause execution upon page.pause in headed mode.
const contextDebugger = new Debugger(this); const contextDebugger = new Debugger(this);
this.instrumentation.addListener(contextDebugger); this.instrumentation.addListener(contextDebugger, this);
// When PWDEBUG=1, show inspector for each context. // When PWDEBUG=1, show inspector for each context.
if (debugMode() === 'inspector') if (debugMode() === 'inspector')

View file

@ -1140,6 +1140,25 @@ export class Frame extends SdkObject {
}, undefined, options); }, 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<boolean> { private async _elementState(metadata: CallMetadata, selector: string, state: ElementStateWithoutStable, options: types.QueryOnSelectorOptions = {}): Promise<boolean> {
const result = await this._scheduleRerunnableTask(metadata, selector, (progress, element, data) => { const result = await this._scheduleRerunnableTask(metadata, selector, (progress, element, data) => {
const injected = progress.injectedScript; const injected = progress.injectedScript;

View file

@ -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;
}
}

View file

@ -23,6 +23,7 @@ import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMat
import { CSSComplexSelectorList } from '../common/cssParser'; import { CSSComplexSelectorList } from '../common/cssParser';
import { generateSelector } from './selectorGenerator'; import { generateSelector } from './selectorGenerator';
import type * as channels from '../../protocol/channels'; import type * as channels from '../../protocol/channels';
import { Highlight } from './highlight';
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol; type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
@ -74,6 +75,7 @@ export class InjectedScript {
private _browserName: string; private _browserName: string;
onGlobalListenersRemoved = new Set<() => void>(); onGlobalListenersRemoved = new Set<() => void>();
private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void); private _hitTargetInterceptor: undefined | ((event: MouseEvent | PointerEvent | TouchEvent) => void);
private _highlight: Highlight | undefined;
constructor(stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) { constructor(stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) {
this._evaluator = new SelectorEvaluatorImpl(new Map()); this._evaluator = new SelectorEvaluatorImpl(new Map());
@ -856,6 +858,21 @@ export class InjectedScript {
return error; 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() { private _setupGlobalListenersRemovalDetection() {
const customEventName = '__playwright_global_listeners_check__'; const customEventName = '__playwright_global_listeners_check__';

View file

@ -50,36 +50,42 @@ export class SdkObject extends EventEmitter {
} }
export interface Instrumentation { export interface Instrumentation {
addListener(listener: InstrumentationListener): void; addListener(listener: InstrumentationListener, context: BrowserContext | null): void;
removeListener(listener: InstrumentationListener): void; removeListener(listener: InstrumentationListener): void;
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>; onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>; onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
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<void>; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onEvent(sdkObject: SdkObject, metadata: CallMetadata): void; onEvent(sdkObject: SdkObject, metadata: CallMetadata): void;
onPageOpen(page: Page): void;
onPageClose(page: Page): void;
} }
export interface InstrumentationListener { export interface InstrumentationListener {
onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>; onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>; onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
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<void>; onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onEvent?(sdkObject: SdkObject, metadata: CallMetadata): void; onEvent?(sdkObject: SdkObject, metadata: CallMetadata): void;
onPageOpen?(page: Page): void;
onPageClose?(page: Page): void;
} }
export function createInstrumentation(): Instrumentation { export function createInstrumentation(): Instrumentation {
const listeners: InstrumentationListener[] = []; const listeners = new Map<InstrumentationListener, BrowserContext | null>();
return new Proxy({}, { return new Proxy({}, {
get: (obj: any, prop: string) => { get: (obj: any, prop: string) => {
if (prop === 'addListener') if (prop === 'addListener')
return (listener: InstrumentationListener) => listeners.push(listener); return (listener: InstrumentationListener, context: BrowserContext | null) => listeners.set(listener, context);
if (prop === 'removeListener') if (prop === 'removeListener')
return (listener: InstrumentationListener) => listeners.splice(listeners.indexOf(listener), 1); return (listener: InstrumentationListener) => listeners.delete(listener);
if (!prop.startsWith('on')) if (!prop.startsWith('on'))
return obj[prop]; return obj[prop];
return async (...params: any[]) => { return async (sdkObject: SdkObject, ...params: any[]) => {
for (const listener of listeners) for (const [listener, context] of listeners) {
await (listener as any)[prop]?.(...params); if (!context || sdkObject.attribution.context === context)
await (listener as any)[prop]?.(sdkObject, ...params);
}
}; };
}, },
}); });

View file

@ -169,6 +169,7 @@ export class Page extends SdkObject {
this.pdf = delegate.pdf.bind(delegate); this.pdf = delegate.pdf.bind(delegate);
this.coverage = delegate.coverage ? delegate.coverage() : null; this.coverage = delegate.coverage ? delegate.coverage() : null;
this.selectors = browserContext.selectors(); this.selectors = browserContext.selectors();
this.instrumentation.onPageOpen(this);
} }
async initOpener(opener: PageDelegate | null) { async initOpener(opener: PageDelegate | null) {
@ -208,6 +209,7 @@ export class Page extends SdkObject {
} }
_didClose() { _didClose() {
this.instrumentation.onPageClose(this);
this._frameManager.dispose(); this._frameManager.dispose();
this._frameThrottler.setEnabled(false); this._frameThrottler.setEnabled(false);
assert(this._closedState !== 'closed', 'Page closed twice'); assert(this._closedState !== 'closed', 'Page closed twice');
@ -217,6 +219,7 @@ export class Page extends SdkObject {
} }
_didCrash() { _didCrash() {
this.instrumentation.onPageClose(this);
this._frameManager.dispose(); this._frameManager.dispose();
this._frameThrottler.setEnabled(false); this._frameThrottler.setEnabled(false);
this.emit(Page.Events.Crash); this.emit(Page.Events.Crash);
@ -224,6 +227,7 @@ export class Page extends SdkObject {
} }
_didDisconnect() { _didDisconnect() {
this.instrumentation.onPageClose(this);
this._frameManager.dispose(); this._frameManager.dispose();
this._frameThrottler.setEnabled(false); this._frameThrottler.setEnabled(false);
assert(!this._disconnected, 'Page disconnected twice'); 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; const strict = typeof options?.strict === 'boolean' ? options.strict : !!this.context()._options.strictSelectors;
return this.selectors.parseSelector(selector, strict); return this.selectors.parseSelector(selector, strict);
} }
async hideHighlight() {
await Promise.all(this.frames().map(frame => frame.hideHighlight().catch(() => {})));
}
} }
export class Worker extends SdkObject { export class Worker extends SdkObject {

View file

@ -24,6 +24,7 @@ import { Selectors } from './selectors';
import { WebKit } from './webkit/webkit'; import { WebKit } from './webkit/webkit';
import { CallMetadata, createInstrumentation, SdkObject } from './instrumentation'; import { CallMetadata, createInstrumentation, SdkObject } from './instrumentation';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { Page } from './page';
export class Playwright extends SdkObject { export class Playwright extends SdkObject {
readonly selectors: Selectors; readonly selectors: Selectors;
@ -33,14 +34,17 @@ export class Playwright extends SdkObject {
readonly firefox: Firefox; readonly firefox: Firefox;
readonly webkit: WebKit; readonly webkit: WebKit;
readonly options: PlaywrightOptions; readonly options: PlaywrightOptions;
private _allPages = new Set<Page>();
constructor(sdkLanguage: string, isInternal: boolean) { constructor(sdkLanguage: string, isInternal: boolean) {
super({ attribution: { isInternal }, instrumentation: createInstrumentation() } as any, undefined, 'Playwright'); super({ attribution: { isInternal }, instrumentation: createInstrumentation() } as any, undefined, 'Playwright');
this.instrumentation.addListener({ 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); debugLogger.log(logName as any, message);
} }
}); }, null);
this.options = { this.options = {
rootSdkObject: this, rootSdkObject: this,
selectors: new Selectors(), selectors: new Selectors(),
@ -53,6 +57,10 @@ export class Playwright extends SdkObject {
this.android = new Android(new AdbBackend(), this.options); this.android = new Android(new AdbBackend(), this.options);
this.selectors = this.options.selectors; 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) { export function createPlaywright(sdkLanguage: string, isInternal: boolean = false) {

View file

@ -82,7 +82,7 @@ export class ProgressController {
if (this._state === 'running') if (this._state === 'running')
this.metadata.log.push(message); this.metadata.log.push(message);
// Note: we might be sending logs after progress has finished, for example browser logs. // 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) if ('intermediateResult' in entry)
this._lastIntermediateResult = entry.intermediateResult; this._lastIntermediateResult = entry.intermediateResult;

View file

@ -67,7 +67,7 @@ export class Debugger extends EventEmitter implements InstrumentationListener {
await this.pause(sdkObject, metadata); await this.pause(sdkObject, metadata);
} }
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): Promise<void> {
debugLogger.log(logName as any, message); debugLogger.log(logName as any, message);
} }

View file

@ -19,6 +19,7 @@ import type InjectedScript from '../../injected/injectedScript';
import { generateSelector, querySelector } from '../../injected/selectorGenerator'; import { generateSelector, querySelector } from '../../injected/selectorGenerator';
import type { Point } from '../../../common/types'; import type { Point } from '../../../common/types';
import type { UIState } from '../recorder/recorderTypes'; import type { UIState } from '../recorder/recorderTypes';
import { Highlight } from '../../injected/highlight';
declare module globalThis { declare module globalThis {
@ -32,11 +33,6 @@ declare module globalThis {
export class Recorder { export class Recorder {
private _injectedScript: InjectedScript; private _injectedScript: InjectedScript;
private _performingAction = false; private _performingAction = false;
private _outerGlassPaneElement: HTMLElement;
private _glassPaneShadow: ShadowRoot;
private _innerGlassPaneElement: HTMLElement;
private _highlightElements: HTMLElement[] = [];
private _tooltipElement: HTMLElement;
private _listeners: (() => void)[] = []; private _listeners: (() => void)[] = [];
private _hoveredModel: HighlightModel | null = null; private _hoveredModel: HighlightModel | null = null;
private _hoveredElement: HTMLElement | null = null; private _hoveredElement: HTMLElement | null = null;
@ -44,76 +40,15 @@ export class Recorder {
private _expectProgrammaticKeyUp = false; private _expectProgrammaticKeyUp = false;
private _pollRecorderModeTimer: NodeJS.Timeout | undefined; private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
private _mode: 'none' | 'inspecting' | 'recording' = 'none'; private _mode: 'none' | 'inspecting' | 'recording' = 'none';
private _actionPointElement: HTMLElement;
private _actionPoint: Point | undefined; private _actionPoint: Point | undefined;
private _actionSelector: string | undefined; private _actionSelector: string | undefined;
private _params: { isUnderTest: boolean; }; private _params: { isUnderTest: boolean; };
private _highlight: Highlight;
constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean }) { constructor(injectedScript: InjectedScript, params: { isUnderTest: boolean }) {
this._params = params; this._params = params;
this._injectedScript = injectedScript; this._injectedScript = injectedScript;
this._outerGlassPaneElement = document.createElement('x-pw-glass'); this._highlight = new Highlight(params.isUnderTest);
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._refreshListenersIfNeeded(); this._refreshListenersIfNeeded();
injectedScript.onGlobalListenersRemoved.add(() => this._refreshListenersIfNeeded()); injectedScript.onGlobalListenersRemoved.add(() => this._refreshListenersIfNeeded());
@ -128,7 +63,7 @@ export class Recorder {
private _refreshListenersIfNeeded() { private _refreshListenersIfNeeded() {
// Ensure we are attached to the current document, and we are on top (last element); // 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; return;
removeEventListeners(this._listeners); removeEventListeners(this._listeners);
this._listeners = [ this._listeners = [
@ -144,11 +79,11 @@ export class Recorder {
addEventListener(document, 'focus', () => this._onFocus(), true), addEventListener(document, 'focus', () => this._onFocus(), true),
addEventListener(document, 'scroll', () => { addEventListener(document, 'scroll', () => {
this._hoveredModel = null; this._hoveredModel = null;
this._actionPointElement.hidden = true; this._highlight.hideActionPoint();
this._updateHighlight(); this._updateHighlight();
}, true), }, true),
]; ];
document.documentElement.appendChild(this._outerGlassPaneElement); this._highlight.install();
} }
private async _pollRecorderMode() { private async _pollRecorderMode() {
@ -171,13 +106,10 @@ export class Recorder {
} else if (!actionPoint && !this._actionPoint) { } else if (!actionPoint && !this._actionPoint) {
// All good. // All good.
} else { } else {
if (actionPoint) { if (actionPoint)
this._actionPointElement.style.top = actionPoint.y + 'px'; this._highlight.showActionPoint(actionPoint.x, actionPoint.y);
this._actionPointElement.style.left = actionPoint.x + 'px'; else
this._actionPointElement.hidden = false; this._highlight.hideActionPoint();
} else {
this._actionPointElement.hidden = true;
}
this._actionPoint = actionPoint; this._actionPoint = actionPoint;
} }
@ -329,75 +261,8 @@ export class Recorder {
private _updateHighlight() { private _updateHighlight() {
const elements = this._hoveredModel ? this._hoveredModel.elements : []; const elements = this._hoveredModel ? this._hoveredModel.elements : [];
const selector = this._hoveredModel ? this._hoveredModel.selector : '';
// Code below should trigger one layout and leave with the this._highlight.updateHighlight(elements, selector, this._mode === 'recording');
// 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;
} }
private _onInput(event: Event) { private _onInput(event: Event) {

View file

@ -72,7 +72,7 @@ export class RecorderSupplement implements InstrumentationListener {
this._contextRecorder = new ContextRecorder(context, params); this._contextRecorder = new ContextRecorder(context, params);
this._context = context; this._context = context;
this._debugger = Debugger.lookup(context)!; this._debugger = Debugger.lookup(context)!;
context.instrumentation.addListener(this); context.instrumentation.addListener(this, context);
} }
async install() { async install() {
@ -248,7 +248,7 @@ export class RecorderSupplement implements InstrumentationListener {
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) {
} }
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): Promise<void> {
this.updateCallLog([metadata]); this.updateCallLog([metadata]);
} }

View file

@ -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'); 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) if (state.options.screenshots)
this._startScreencast(); this._startScreencast();
if (state.options.snapshots) if (state.options.snapshots)