diff --git a/src/browserContext.ts b/src/browserContext.ts index ea02a152ad..6b32bfb26c 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -26,6 +26,7 @@ import { Download } from './download'; import { BrowserBase } from './browser'; import { Log, InnerLogger, Logger, RootLogger } from './logger'; import { FunctionWithSource } from './frames'; +import * as debugSupport from './debug/debugSupport'; export type PersistentContextOptions = { viewport?: types.Size | null, @@ -95,6 +96,10 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); } + async _initialize() { + await debugSupport.installConsoleHelpers(this); + } + protected _abortPromiseForEvent(event: string) { return event === Events.BrowserContext.Close ? super._abortPromiseForEvent(event) : this._closePromise; } diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index fd5f1bc822..a83967f8af 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -291,7 +291,7 @@ export class CRBrowserContext extends BrowserContextBase { async _initialize() { assert(!Array.from(this._browser._crPages.values()).some(page => page._browserContext === this)); - const promises: Promise[] = []; + const promises: Promise[] = [ super._initialize() ]; if (this._browser._options.downloadsPath) { promises.push(this._browser._session.send('Browser.setDownloadBehavior', { behavior: this._options.acceptDownloads ? 'allowAndName' : 'deny', diff --git a/src/debug/debugSupport.ts b/src/debug/debugSupport.ts index a186d89904..af7456866c 100644 --- a/src/debug/debugSupport.ts +++ b/src/debug/debugSupport.ts @@ -16,6 +16,13 @@ import * as sourceMap from './sourceMap'; import { getFromENV } from '../helper'; +import { BrowserContextBase } from '../browserContext'; +import { Frame } from '../frames'; +import { Events } from '../events'; +import { Page } from '../page'; +import { parseSelector } from '../selectors'; +import * as types from '../types'; +import InjectedScript from '../injected/injectedScript'; let debugMode: boolean | undefined; export function isDebugMode(): boolean { @@ -45,3 +52,96 @@ export async function generateSourceMapUrl(functionText: string, generatedText: const sourceMapUrl = await sourceMap.generateSourceMapUrl(functionText, generatedText); return sourceMapUrl || generateSourceUrl(); } + +export async function installConsoleHelpers(context: BrowserContextBase) { + if (!isDebugMode()) + return; + const installInFrame = async (frame: Frame) => { + try { + const mainContext = await frame._mainContext(); + const injectedScript = await mainContext.injectedScript(); + await injectedScript.evaluate(installPlaywrightObjectOnWindow, parseSelector.toString()); + } catch (e) { + } + }; + context.on(Events.BrowserContext.Page, (page: Page) => { + installInFrame(page.mainFrame()); + page.on(Events.Page.FrameNavigated, installInFrame); + }); +} + +function installPlaywrightObjectOnWindow(injectedScript: InjectedScript, parseSelectorFunctionString: string) { + const parseSelector: (selector: string) => types.ParsedSelector = + new Function('...args', 'return (' + parseSelectorFunctionString + ')(...args)') as any; + + const highlightContainer = document.createElement('div'); + highlightContainer.style.cssText = 'position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 10000;'; + + function checkSelector(parsed: types.ParsedSelector) { + for (const {name} of parsed.parts) { + if (!injectedScript.engines.has(name)) + throw new Error(`Unknown engine "${name}"`); + } + } + + function highlightElements(elements: Element[] = [], target?: Element) { + const scrollLeft = document.scrollingElement ? document.scrollingElement.scrollLeft : 0; + const scrollTop = document.scrollingElement ? document.scrollingElement.scrollTop : 0; + highlightContainer.textContent = ''; + for (const element of elements) { + const rect = element.getBoundingClientRect(); + const highlight = document.createElement('div'); + highlight.style.position = 'absolute'; + highlight.style.left = (rect.left + scrollLeft) + 'px'; + highlight.style.top = (rect.top + scrollTop) + 'px'; + highlight.style.height = rect.height + 'px'; + highlight.style.width = rect.width + 'px'; + highlight.style.pointerEvents = 'none'; + if (element === target) { + highlight.style.background = 'hsla(30, 97%, 37%, 0.3)'; + highlight.style.border = '3px solid hsla(30, 97%, 37%, 0.6)'; + } else { + highlight.style.background = 'hsla(120, 100%, 37%, 0.3)'; + highlight.style.border = '3px solid hsla(120, 100%, 37%, 0.8)'; + } + highlight.style.borderRadius = '3px'; + highlightContainer.appendChild(highlight); + } + document.body.appendChild(highlightContainer); + } + + function $(selector: string): (Element | undefined) { + if (typeof selector !== 'string') + throw new Error(`Usage: playwright.query('Playwright >> selector').`); + const parsed = parseSelector(selector); + checkSelector(parsed); + const elements = injectedScript.querySelectorAll(parsed, document); + highlightElements(elements, elements[0]); + return elements[0]; + } + + function $$(selector: string): Element[] { + if (typeof selector !== 'string') + throw new Error(`Usage: playwright.$$('Playwright >> selector').`); + const parsed = parseSelector(selector); + checkSelector(parsed); + const elements = injectedScript.querySelectorAll(parsed, document); + highlightElements(elements); + return elements; + } + + function inspect(selector: string) { + if (typeof (window as any).inspect !== 'function') + return; + if (typeof selector !== 'string') + throw new Error(`Usage: playwright.inspect('Playwright >> selector').`); + highlightElements(); + (window as any).inspect($(selector)); + } + + function clear() { + highlightContainer.remove(); + } + + (window as any).playwright = { $, $$, inspect, clear }; +} diff --git a/src/firefox/ffBrowser.ts b/src/firefox/ffBrowser.ts index f3aeef1a8c..9e3b404a83 100644 --- a/src/firefox/ffBrowser.ts +++ b/src/firefox/ffBrowser.ts @@ -157,7 +157,7 @@ export class FFBrowserContext extends BrowserContextBase { async _initialize() { assert(!this._ffPages().length); const browserContextId = this._browserContextId || undefined; - const promises: Promise[] = []; + const promises: Promise[] = [ super._initialize() ]; if (this._browser._options.downloadsPath) { promises.push(this._browser._connection.send('Browser.setDownloadOptions', { browserContextId, diff --git a/src/injected/injectedScript.ts b/src/injected/injectedScript.ts index c0e0a15b85..36203b985d 100644 --- a/src/injected/injectedScript.ts +++ b/src/injected/injectedScript.ts @@ -378,7 +378,7 @@ export default class InjectedScript { if (!element || !element.isConnected) return { status: 'notconnected' }; element = element.closest('button, [role=button]') || element; - let hitElement = this._deepElementFromPoint(document, point.x, point.y); + let hitElement = this.deepElementFromPoint(document, point.x, point.y); while (hitElement && hitElement !== element) hitElement = this._parentElementOrShadowHost(hitElement); return { status: 'success', value: hitElement === element }; @@ -408,7 +408,7 @@ export default class InjectedScript { return (element.parentNode as ShadowRoot).host; } - private _deepElementFromPoint(document: Document, x: number, y: number): Element | undefined { + deepElementFromPoint(document: Document, x: number, y: number): Element | undefined { let container: Document | ShadowRoot | null = document; let element: Element | undefined; while (container) { diff --git a/src/selectors.ts b/src/selectors.ts index 4ae224bb40..97a99239d7 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -160,66 +160,73 @@ export class Selectors { private _parseSelector(selector: string): types.ParsedSelector { assert(helper.isString(selector), `selector must be a string`); - let index = 0; - let quote: string | undefined; - let start = 0; - const result: types.ParsedSelector = { parts: [] }; - const append = () => { - const part = selector.substring(start, index).trim(); - const eqIndex = part.indexOf('='); - let name: string; - let body: string; - if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) { - name = part.substring(0, eqIndex).trim(); - body = part.substring(eqIndex + 1); - } else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') { - name = 'text'; - body = part; - } else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") { - name = 'text'; - body = part; - } else if (/^\(*\/\//.test(part)) { - // If selector starts with '//' or '//' prefixed with multiple opening - // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 - name = 'xpath'; - body = part; - } else { - name = 'css'; - body = part; - } - name = name.toLowerCase(); - let capture = false; - if (name[0] === '*') { - capture = true; - name = name.substring(1); - } + const parsed = parseSelector(selector); + for (const {name} of parsed.parts) { if (!this._builtinEngines.has(name) && !this._engines.has(name)) throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`); - result.parts.push({ name, body }); - if (capture) { - if (result.capture !== undefined) - throw new Error(`Only one of the selectors can capture using * modifier`); - result.capture = result.parts.length - 1; - } - }; - while (index < selector.length) { - const c = selector[index]; - if (c === '\\' && index + 1 < selector.length) { - index += 2; - } else if (c === quote) { - quote = undefined; - index++; - } else if (!quote && c === '>' && selector[index + 1] === '>') { - append(); - index += 2; - start = index; - } else { - index++; - } } - append(); - return result; + return parsed; } } export const selectors = new Selectors(); + +export function parseSelector(selector: string): types.ParsedSelector { + let index = 0; + let quote: string | undefined; + let start = 0; + const result: types.ParsedSelector = { parts: [] }; + const append = () => { + const part = selector.substring(start, index).trim(); + const eqIndex = part.indexOf('='); + let name: string; + let body: string; + if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) { + name = part.substring(0, eqIndex).trim(); + body = part.substring(eqIndex + 1); + } else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') { + name = 'text'; + body = part; + } else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") { + name = 'text'; + body = part; + } else if (/^\(*\/\//.test(part)) { + // If selector starts with '//' or '//' prefixed with multiple opening + // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 + name = 'xpath'; + body = part; + } else { + name = 'css'; + body = part; + } + name = name.toLowerCase(); + let capture = false; + if (name[0] === '*') { + capture = true; + name = name.substring(1); + } + result.parts.push({ name, body }); + if (capture) { + if (result.capture !== undefined) + throw new Error(`Only one of the selectors can capture using * modifier`); + result.capture = result.parts.length - 1; + } + }; + while (index < selector.length) { + const c = selector[index]; + if (c === '\\' && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = undefined; + index++; + } else if (!quote && c === '>' && selector[index + 1] === '>') { + append(); + index += 2; + start = index; + } else { + index++; + } + } + append(); + return result; +} diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts index 5c66d08a01..bfdff190b5 100644 --- a/src/webkit/wkBrowser.ts +++ b/src/webkit/wkBrowser.ts @@ -213,7 +213,7 @@ export class WKBrowserContext extends BrowserContextBase { async _initialize() { assert(!this._wkPages().length); const browserContextId = this._browserContextId; - const promises: Promise[] = []; + const promises: Promise[] = [ super._initialize() ]; if (this._browser._options.downloadsPath) { promises.push(this._browser._browserSession.send('Playwright.setDownloadBehavior', { behavior: this._options.acceptDownloads ? 'allow' : 'deny',