From 2bef4aea03e4880487b121d9a57c658c80b2fa2f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jan 2020 11:20:34 -0800 Subject: [PATCH] feat(api): introduce selectors.register method (#701) --- docs/api.md | 63 ++++++++++++++++++++++++++++++ index.d.ts | 1 + index.js | 1 + src/api.ts | 1 + src/dom.ts | 12 ++++-- src/page.ts | 8 ---- src/selectors.ts | 48 +++++++++++++++++++++++ test/playwright.spec.js | 4 +- test/queryselector.spec.js | 79 ++++++++++++++++++++++++++++++-------- 9 files changed, 188 insertions(+), 29 deletions(-) create mode 100644 src/selectors.ts diff --git a/docs/api.md b/docs/api.md index 0ab40f0aeb..6e817d2ba8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,7 @@ - [class: Mouse](#class-mouse) - [class: Request](#class-request) - [class: Response](#class-response) +- [class: Selectors](#class-selectors) - [class: WebSocket](#class-websocket) - [class: TimeoutError](#class-timeouterror) - [class: Accessibility](#class-accessibility) @@ -57,6 +58,7 @@ Playwright automatically downloads browser executables during installation, see - [playwright.devices](#playwrightdevices) - [playwright.errors](#playwrighterrors) - [playwright.firefox](#playwrightfirefox) +- [playwright.selectors](#playwrightselectors) - [playwright.webkit](#playwrightwebkit) @@ -113,6 +115,11 @@ try { This object can be used to launch or connect to Firefox, returning instances of [FirefoxBrowser]. +#### playwright.selectors +- returns: <[Selectors]> + +Selectors can be used to install custom selector engines. See [Working with selectors](#working-with-selectors) for more information. + #### playwright.webkit - returns: <[BrowserType]> @@ -3016,6 +3023,61 @@ Contains the status text of the response (e.g. usually an "OK" for a success). Contains the URL of the response. +### class: Selectors + +Selectors can be used to install custom selector engines. See [Working with selectors](#working-with-selectors) for more information. + + +- [selectors.register(engineSource)](#selectorsregisterenginesource) + + +#### selectors.register(engineSource) +- `engineSource` <[string]> String which evaluates to a selector engine instance. +- returns: <[Promise]> + +An example of registering selector engine which selects nodes based on tag name: +```js +const { selectors, firefox } = require('playwright'); // Or 'chromium' or 'webkit'. + +(async () => { + const createTagSelector = () => ({ + // Selectors will be prefixed with "tag=". + name: 'tag', + + // Creates a selector which matches given target when queried at the root. + create(root, target) { + return target.tagName; + }, + + // Returns the first element matching given selector in the root's subtree. + query(root, selector) { + return root.querySelector(selector); + }, + + // Returns all elements matching given selector in the root's subtree. + queryAll(root, selector) { + return Array.from(root.querySelectorAll(selector)); + } + }); + + // Construct an expression which evaluates to our selector instance. + await selectors.register(`(${createTagSelector.toString()})()`); + + const browser = await firefox.launch(); + const context = await browser.newContext(); + const page = await context.newPage('http://example.com'); + + // Use the selector prefixed with its name. + const button = await page.$('tag=button'); + // Combine it with other selector engines. + await page.click('tag=div >> text="Click me"'); + // Can use it in any methods supporting selectors. + const buttonCount = await page.$$eval('tag=button', buttons => buttons.length); + + await browser.close(); +})(); +``` + ### class: WebSocket The [WebSocket] class represents websocket connections in the page. @@ -3779,6 +3841,7 @@ const { chromium } = require('playwright'); [RegExp]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp [Request]: #class-request "Request" [Response]: #class-response "Response" +[Selectors]: #class-selectors "Selectors" [Serializable]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Description "Serializable" [Target]: #class-target "Target" [TimeoutError]: #class-timeouterror "TimeoutError" diff --git a/index.d.ts b/index.d.ts index 165f02df05..d6137f14fc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -20,4 +20,5 @@ export const errors: { TimeoutError: typeof import('./lib/errors').TimeoutError export const chromium: import('./lib/server/chromium').Chromium; export const firefox: import('./lib/server/firefox').Firefox; export const webkit: import('./lib/server/webkit').WebKit; +export const selectors: import('./lib/api').Selectors; export type PlaywrightWeb = typeof import('./lib/web'); diff --git a/index.js b/index.js index feee61d4da..4137eff9e1 100644 --- a/index.js +++ b/index.js @@ -31,6 +31,7 @@ for (const className in api) { module.exports = { devices: DeviceDescriptors, errors: { TimeoutError }, + selectors: api.Selectors._instance(), chromium: new Chromium(__dirname, packageJson.playwright.chromium_revision), firefox: new Firefox(__dirname, packageJson.playwright.firefox_revision), webkit: new WebKit(__dirname, packageJson.playwright.webkit_revision), diff --git a/src/api.ts b/src/api.ts index ea74b6c647..f78f9df7fd 100644 --- a/src/api.ts +++ b/src/api.ts @@ -26,6 +26,7 @@ export { Keyboard, Mouse } from './input'; export { JSHandle } from './javascript'; export { Request, Response, WebSocket } from './network'; export { Coverage, FileChooser, Page, Worker } from './page'; +export { Selectors } from './selectors'; export { CRBrowser as ChromiumBrowser } from './chromium/crBrowser'; export { CRSession as ChromiumSession } from './chromium/crConnection'; diff --git a/src/dom.ts b/src/dom.ts index f5772a4b5d..875a032240 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -19,16 +19,17 @@ import * as input from './input'; import * as js from './javascript'; import * as types from './types'; import * as injectedSource from './generated/injectedSource'; -import * as zsSelectorEngineSource from './generated/zsSelectorEngineSource'; import { assert, helper, debugError } from './helper'; import Injected from './injected/injected'; import { Page } from './page'; import * as platform from './platform'; +import { Selectors } from './selectors'; export class FrameExecutionContext extends js.ExecutionContext { readonly frame: frames.Frame; private _injectedPromise?: Promise; + private _injectedGeneration = -1; constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) { super(delegate); @@ -69,14 +70,19 @@ export class FrameExecutionContext extends js.ExecutionContext { } _injected(): Promise { + const selectors = Selectors._instance(); + if (this._injectedPromise && selectors._generation !== this._injectedGeneration) { + this._injectedPromise.then(handle => handle.dispose()); + this._injectedPromise = undefined; + } if (!this._injectedPromise) { - const additionalEngineSources = [zsSelectorEngineSource.source]; const source = ` new (${injectedSource.source})([ - ${additionalEngineSources.join(',\n')}, + ${selectors._sources.join(',\n')}, ]) `; this._injectedPromise = this.evaluateHandle(source); + this._injectedGeneration = selectors._generation; } return this._injectedPromise; } diff --git a/src/page.ts b/src/page.ts index 8838e87178..57ec740833 100644 --- a/src/page.ts +++ b/src/page.ts @@ -27,7 +27,6 @@ import * as types from './types'; import { Events } from './events'; import { BrowserContext } from './browserContext'; import { ConsoleMessage, ConsoleMessageLocation } from './console'; -import Injected from './injected/injected'; import * as accessibility from './accessibility'; import * as platform from './platform'; @@ -197,13 +196,6 @@ export class Page extends platform.EventEmitter { return this.mainFrame().waitForSelector(selector, options); } - async _createSelector(name: string, handle: dom.ElementHandle): Promise { - const mainContext = await this.mainFrame()._mainContext(); - return mainContext.evaluate((injected: Injected, target: Element, name: string) => { - return injected.engines.get(name)!.create(document.documentElement, target); - }, await mainContext._injected(), handle, name); - } - evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => { return this.mainFrame().evaluateHandle(pageFunction, ...args as any); } diff --git a/src/selectors.ts b/src/selectors.ts new file mode 100644 index 0000000000..4446d6b845 --- /dev/null +++ b/src/selectors.ts @@ -0,0 +1,48 @@ +/** + * 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. + */ + +import * as zsSelectorEngineSource from './generated/zsSelectorEngineSource'; +import * as dom from './dom'; +import Injected from './injected/injected'; + +let selectors: Selectors; + +export class Selectors { + readonly _sources: string[]; + _generation = 0; + + static _instance() { + if (!selectors) + selectors = new Selectors(); + return selectors; + } + + constructor() { + this._sources = [zsSelectorEngineSource.source]; + } + + async register(engineSource: string) { + this._sources.push(engineSource); + ++this._generation; + } + + async _createSelector(name: string, handle: dom.ElementHandle): Promise { + const mainContext = await handle._page.mainFrame()._mainContext(); + return mainContext.evaluate((injected: Injected, target: Element, name: string) => { + return injected.engines.get(name)!.create(document.documentElement, target); + }, await mainContext._injected(), handle, name); + } +} diff --git a/test/playwright.spec.js b/test/playwright.spec.js index d64138e39d..515a41ff95 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -36,7 +36,8 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { const LINUX = os.platform() === 'linux'; const WIN = os.platform() === 'win32'; - const playwright = require(playwrightPath)[product.toLowerCase()]; + const playwrightModule = require(playwrightPath); + const playwright = playwrightModule[product.toLowerCase()]; const headless = (process.env.HEADLESS || 'true').trim().toLowerCase() === 'true'; const slowMo = parseInt((process.env.SLOW_MO || '0').trim(), 10); @@ -81,6 +82,7 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => { LINUX, WIN, playwright, + selectors: playwrightModule.selectors, expect, defaultBrowserOptions, playwrightPath, diff --git a/test/queryselector.spec.js b/test/queryselector.spec.js index 8a60d23235..76a1a8508d 100644 --- a/test/queryselector.spec.js +++ b/test/queryselector.spec.js @@ -15,7 +15,7 @@ * limitations under the License. */ -module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM, WEBKIT}) { +module.exports.describe = function({testRunner, expect, selectors, FFOX, CHROMIUM, WEBKIT}) { const {describe, xdescribe, fdescribe} = testRunner; const {it, fit, xit, dit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; @@ -393,28 +393,28 @@ module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM, it('create', async ({page}) => { await page.setContent(`
yo
ya
ya
`); - expect(await page._createSelector('zs', await page.$('div'))).toBe('"yo"'); - expect(await page._createSelector('zs', await page.$('div:nth-child(2)'))).toBe('"ya"'); - expect(await page._createSelector('zs', await page.$('div:nth-child(3)'))).toBe('"ya"#1'); + expect(await selectors._createSelector('zs', await page.$('div'))).toBe('"yo"'); + expect(await selectors._createSelector('zs', await page.$('div:nth-child(2)'))).toBe('"ya"'); + expect(await selectors._createSelector('zs', await page.$('div:nth-child(3)'))).toBe('"ya"#1'); await page.setContent(`foo bar`); - expect(await page._createSelector('zs', await page.$('img'))).toBe('img[alt="foo bar"]'); + expect(await selectors._createSelector('zs', await page.$('img'))).toBe('img[alt="foo bar"]'); await page.setContent(`
yo
`); - expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"~SPAN'); - expect(await page._createSelector('zs', await page.$('span:nth-child(2)'))).toBe('SPAN#1'); + expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"~SPAN'); + expect(await selectors._createSelector('zs', await page.$('span:nth-child(2)'))).toBe('SPAN#1'); }); it('children of various display parents', async ({page}) => { await page.setContent(`
yo
`); - expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"'); + expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"'); await page.setContent(`
yo
`); - expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"'); + expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"'); // "display: none" makes all children text invisible - fallback to tag name. await page.setContent(`
yo
`); - expect(await page._createSelector('zs', await page.$('span'))).toBe('SPAN'); + expect(await selectors._createSelector('zs', await page.$('span'))).toBe('SPAN'); }); it('boundary', async ({page}) => { @@ -465,7 +465,7 @@ module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM,
hello
`); - expect(await page._createSelector('zs', await page.$('#target'))).toBe('"ya"~"hey"~"hello"'); + expect(await selectors._createSelector('zs', await page.$('#target'))).toBe('"ya"~"hey"~"hello"'); expect(await page.$eval(`zs="ya"~"hey"~"hello"`, e => e.outerHTML)).toBe('
hello
'); expect(await page.$eval(`zs="ya"~"hey"~"unique"`, e => e.outerHTML).catch(e => e.message)).toBe('Error: failed to find element matching selector "zs="ya"~"hey"~"unique""'); expect(await page.$$eval(`zs="ya" ~ "hey" ~ "hello"`, es => es.map(e => e.outerHTML).join('\n'))).toBe('
hello
\n
hello
'); @@ -498,18 +498,63 @@ module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM, it('create', async ({page}) => { await page.setContent(`
yo
"ya
ye ye
`); - expect(await page._createSelector('text', await page.$('div'))).toBe('yo'); - expect(await page._createSelector('text', await page.$('div:nth-child(2)'))).toBe('"\\"ya"'); - expect(await page._createSelector('text', await page.$('div:nth-child(3)'))).toBe('"ye ye"'); + expect(await selectors._createSelector('text', await page.$('div'))).toBe('yo'); + expect(await selectors._createSelector('text', await page.$('div:nth-child(2)'))).toBe('"\\"ya"'); + expect(await selectors._createSelector('text', await page.$('div:nth-child(3)'))).toBe('"ye ye"'); await page.setContent(`
yo
yo
ya
hey
`); - expect(await page._createSelector('text', await page.$('div:nth-child(2)'))).toBe('hey'); + expect(await selectors._createSelector('text', await page.$('div:nth-child(2)'))).toBe('hey'); await page.setContent(`
yo
ya
`); - expect(await page._createSelector('text', await page.$('div'))).toBe('yo'); + expect(await selectors._createSelector('text', await page.$('div'))).toBe('yo'); await page.setContent(`
"yo
ya
`); - expect(await page._createSelector('text', await page.$('div'))).toBe('" \\"yo "'); + expect(await selectors._createSelector('text', await page.$('div'))).toBe('" \\"yo "'); + }); + }); + + describe('selectors.register', () => { + it('should work', async ({page}) => { + const createTagSelector = () => ({ + name: 'tag', + create(root, target) { + return target.nodeName; + }, + query(root, selector) { + return root.querySelector(selector); + }, + queryAll(root, selector) { + return Array.from(root.querySelectorAll(selector)); + } + }); + await selectors.register(`(${createTagSelector.toString()})()`); + await page.setContent('
'); + expect(await selectors._createSelector('tag', await page.$('div'))).toBe('DIV'); + expect(await page.$eval('tag=DIV', e => e.nodeName)).toBe('DIV'); + expect(await page.$eval('tag=SPAN', e => e.nodeName)).toBe('SPAN'); + expect(await page.$$eval('tag=DIV', es => es.length)).toBe(2); + }); + it('should update', async ({page}) => { + await page.setContent('
'); + expect(await page.$eval('div', e => e.nodeName)).toBe('DIV'); + const error = await page.$('dummy=foo').catch(e => e); + expect(error.message).toContain('Unknown engine dummy while parsing selector dummy=foo'); + await selectors.register(` + ({ + name: 'dummy', + create(root, target) { + return target.nodeName; + }, + query(root, selector) { + return root.querySelector('dummy'); + }, + queryAll(root, selector) { + return Array.from(root.querySelectorAll('dummy')); + } + }) + `); + expect(await page.$eval('dummy=foo', e => e.id)).toBe('d1'); + expect(await page.$eval('css=span >> dummy=foo', e => e.id)).toBe('d2'); }); }); };