feat(api): introduce selectors.register method (#701)

This commit is contained in:
Dmitry Gozman 2020-01-28 11:20:34 -08:00 committed by GitHub
parent 9cd61571f1
commit 2bef4aea03
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 188 additions and 29 deletions

View File

@ -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)
<!-- GEN:stop -->
@ -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.
<!-- GEN:toc -->
- [selectors.register(engineSource)](#selectorsregisterenginesource)
<!-- GEN:stop -->
#### 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"

1
index.d.ts vendored
View File

@ -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');

View File

@ -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),

View File

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

View File

@ -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<js.JSHandle>;
private _injectedGeneration = -1;
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) {
super(delegate);
@ -69,14 +70,19 @@ export class FrameExecutionContext extends js.ExecutionContext {
}
_injected(): Promise<js.JSHandle> {
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;
}

View File

@ -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<Element>): Promise<string | undefined> {
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);
}

48
src/selectors.ts Normal file
View File

@ -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<Element>): Promise<string | undefined> {
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);
}
}

View File

@ -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,

View File

@ -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(`<div>yo</div><div>ya</div><div>ya</div>`);
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(`<img alt="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(`<div>yo<span></span></div><span></span>`);
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(`<body><div style='position: fixed;'><span>yo</span></div></body>`);
expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"');
expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"');
await page.setContent(`<div style='position: relative;'><span>yo</span></div>`);
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(`<div style='display: none;'><span>yo</span></div>`);
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,
<div id=target2>hello</div>
</div>
</div>`);
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('<div id="target">hello</div>');
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('<div id="target">hello</div>\n<div id="target2">hello</div>');
@ -498,18 +498,63 @@ module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM,
it('create', async ({page}) => {
await page.setContent(`<div>yo</div><div>"ya</div><div>ye ye</div>`);
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(`<div>yo</div><div>yo<div>ya</div>hey</div>`);
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(`<div> yo <div></div>ya</div>`);
expect(await page._createSelector('text', await page.$('div'))).toBe('yo');
expect(await selectors._createSelector('text', await page.$('div'))).toBe('yo');
await page.setContent(`<div> "yo <div></div>ya</div>`);
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('<div><span></span></div><div></div>');
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('<div><dummy id=d1></dummy></div><span><dummy id=d2></dummy></span>');
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');
});
});
};