Skip to content

Commit

Permalink
feat(debug): expose playwright object in console (#2365)
Browse files Browse the repository at this point in the history
- playwright.$ and playwright.$$ to query elements;
- playwright.inspect to reveal an element;
- playwright.clear to remove highlight.
  • Loading branch information
dgozman committed May 28, 2020
1 parent 0753c2d commit ece4789
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 61 deletions.
5 changes: 5 additions & 0 deletions src/browserContext.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/chromium/crBrowser.ts
Expand Up @@ -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<any>[] = [];
const promises: Promise<any>[] = [ super._initialize() ];
if (this._browser._options.downloadsPath) {
promises.push(this._browser._session.send('Browser.setDownloadBehavior', {
behavior: this._options.acceptDownloads ? 'allowAndName' : 'deny',
Expand Down
100 changes: 100 additions & 0 deletions src/debug/debugSupport.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}
2 changes: 1 addition & 1 deletion src/firefox/ffBrowser.ts
Expand Up @@ -157,7 +157,7 @@ export class FFBrowserContext extends BrowserContextBase {
async _initialize() {
assert(!this._ffPages().length);
const browserContextId = this._browserContextId || undefined;
const promises: Promise<any>[] = [];
const promises: Promise<any>[] = [ super._initialize() ];
if (this._browser._options.downloadsPath) {
promises.push(this._browser._connection.send('Browser.setDownloadOptions', {
browserContextId,
Expand Down
4 changes: 2 additions & 2 deletions src/injected/injectedScript.ts
Expand Up @@ -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 };
Expand Down Expand Up @@ -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) {
Expand Down
119 changes: 63 additions & 56 deletions src/selectors.ts
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion src/webkit/wkBrowser.ts
Expand Up @@ -213,7 +213,7 @@ export class WKBrowserContext extends BrowserContextBase {
async _initialize() {
assert(!this._wkPages().length);
const browserContextId = this._browserContextId;
const promises: Promise<any>[] = [];
const promises: Promise<any>[] = [ super._initialize() ];
if (this._browser._options.downloadsPath) {
promises.push(this._browser._browserSession.send('Playwright.setDownloadBehavior', {
behavior: this._options.acceptDownloads ? 'allow' : 'deny',
Expand Down

0 comments on commit ece4789

Please sign in to comment.