Skip to content

Commit 2bef4ae

Browse files
authored
feat(api): introduce selectors.register method (#701)
1 parent 9cd6157 commit 2bef4ae

File tree

9 files changed

+188
-29
lines changed

9 files changed

+188
-29
lines changed

docs/api.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- [class: Mouse](#class-mouse)
1919
- [class: Request](#class-request)
2020
- [class: Response](#class-response)
21+
- [class: Selectors](#class-selectors)
2122
- [class: WebSocket](#class-websocket)
2223
- [class: TimeoutError](#class-timeouterror)
2324
- [class: Accessibility](#class-accessibility)
@@ -57,6 +58,7 @@ Playwright automatically downloads browser executables during installation, see
5758
- [playwright.devices](#playwrightdevices)
5859
- [playwright.errors](#playwrighterrors)
5960
- [playwright.firefox](#playwrightfirefox)
61+
- [playwright.selectors](#playwrightselectors)
6062
- [playwright.webkit](#playwrightwebkit)
6163
<!-- GEN:stop -->
6264

@@ -113,6 +115,11 @@ try {
113115

114116
This object can be used to launch or connect to Firefox, returning instances of [FirefoxBrowser].
115117

118+
#### playwright.selectors
119+
- returns: <[Selectors]>
120+
121+
Selectors can be used to install custom selector engines. See [Working with selectors](#working-with-selectors) for more information.
122+
116123
#### playwright.webkit
117124
- returns: <[BrowserType]>
118125

@@ -3016,6 +3023,61 @@ Contains the status text of the response (e.g. usually an "OK" for a success).
30163023

30173024
Contains the URL of the response.
30183025

3026+
### class: Selectors
3027+
3028+
Selectors can be used to install custom selector engines. See [Working with selectors](#working-with-selectors) for more information.
3029+
3030+
<!-- GEN:toc -->
3031+
- [selectors.register(engineSource)](#selectorsregisterenginesource)
3032+
<!-- GEN:stop -->
3033+
3034+
#### selectors.register(engineSource)
3035+
- `engineSource` <[string]> String which evaluates to a selector engine instance.
3036+
- returns: <[Promise]>
3037+
3038+
An example of registering selector engine which selects nodes based on tag name:
3039+
```js
3040+
const { selectors, firefox } = require('playwright'); // Or 'chromium' or 'webkit'.
3041+
3042+
(async () => {
3043+
const createTagSelector = () => ({
3044+
// Selectors will be prefixed with "tag=".
3045+
name: 'tag',
3046+
3047+
// Creates a selector which matches given target when queried at the root.
3048+
create(root, target) {
3049+
return target.tagName;
3050+
},
3051+
3052+
// Returns the first element matching given selector in the root's subtree.
3053+
query(root, selector) {
3054+
return root.querySelector(selector);
3055+
},
3056+
3057+
// Returns all elements matching given selector in the root's subtree.
3058+
queryAll(root, selector) {
3059+
return Array.from(root.querySelectorAll(selector));
3060+
}
3061+
});
3062+
3063+
// Construct an expression which evaluates to our selector instance.
3064+
await selectors.register(`(${createTagSelector.toString()})()`);
3065+
3066+
const browser = await firefox.launch();
3067+
const context = await browser.newContext();
3068+
const page = await context.newPage('http://example.com');
3069+
3070+
// Use the selector prefixed with its name.
3071+
const button = await page.$('tag=button');
3072+
// Combine it with other selector engines.
3073+
await page.click('tag=div >> text="Click me"');
3074+
// Can use it in any methods supporting selectors.
3075+
const buttonCount = await page.$$eval('tag=button', buttons => buttons.length);
3076+
3077+
await browser.close();
3078+
})();
3079+
```
3080+
30193081
### class: WebSocket
30203082

30213083
The [WebSocket] class represents websocket connections in the page.
@@ -3779,6 +3841,7 @@ const { chromium } = require('playwright');
37793841
[RegExp]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp
37803842
[Request]: #class-request "Request"
37813843
[Response]: #class-response "Response"
3844+
[Selectors]: #class-selectors "Selectors"
37823845
[Serializable]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Description "Serializable"
37833846
[Target]: #class-target "Target"
37843847
[TimeoutError]: #class-timeouterror "TimeoutError"

index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ export const errors: { TimeoutError: typeof import('./lib/errors').TimeoutError
2020
export const chromium: import('./lib/server/chromium').Chromium;
2121
export const firefox: import('./lib/server/firefox').Firefox;
2222
export const webkit: import('./lib/server/webkit').WebKit;
23+
export const selectors: import('./lib/api').Selectors;
2324
export type PlaywrightWeb = typeof import('./lib/web');

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ for (const className in api) {
3131
module.exports = {
3232
devices: DeviceDescriptors,
3333
errors: { TimeoutError },
34+
selectors: api.Selectors._instance(),
3435
chromium: new Chromium(__dirname, packageJson.playwright.chromium_revision),
3536
firefox: new Firefox(__dirname, packageJson.playwright.firefox_revision),
3637
webkit: new WebKit(__dirname, packageJson.playwright.webkit_revision),

src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { Keyboard, Mouse } from './input';
2626
export { JSHandle } from './javascript';
2727
export { Request, Response, WebSocket } from './network';
2828
export { Coverage, FileChooser, Page, Worker } from './page';
29+
export { Selectors } from './selectors';
2930

3031
export { CRBrowser as ChromiumBrowser } from './chromium/crBrowser';
3132
export { CRSession as ChromiumSession } from './chromium/crConnection';

src/dom.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@ import * as input from './input';
1919
import * as js from './javascript';
2020
import * as types from './types';
2121
import * as injectedSource from './generated/injectedSource';
22-
import * as zsSelectorEngineSource from './generated/zsSelectorEngineSource';
2322
import { assert, helper, debugError } from './helper';
2423
import Injected from './injected/injected';
2524
import { Page } from './page';
2625
import * as platform from './platform';
26+
import { Selectors } from './selectors';
2727

2828
export class FrameExecutionContext extends js.ExecutionContext {
2929
readonly frame: frames.Frame;
3030

3131
private _injectedPromise?: Promise<js.JSHandle>;
32+
private _injectedGeneration = -1;
3233

3334
constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) {
3435
super(delegate);
@@ -69,14 +70,19 @@ export class FrameExecutionContext extends js.ExecutionContext {
6970
}
7071

7172
_injected(): Promise<js.JSHandle> {
73+
const selectors = Selectors._instance();
74+
if (this._injectedPromise && selectors._generation !== this._injectedGeneration) {
75+
this._injectedPromise.then(handle => handle.dispose());
76+
this._injectedPromise = undefined;
77+
}
7278
if (!this._injectedPromise) {
73-
const additionalEngineSources = [zsSelectorEngineSource.source];
7479
const source = `
7580
new (${injectedSource.source})([
76-
${additionalEngineSources.join(',\n')},
81+
${selectors._sources.join(',\n')},
7782
])
7883
`;
7984
this._injectedPromise = this.evaluateHandle(source);
85+
this._injectedGeneration = selectors._generation;
8086
}
8187
return this._injectedPromise;
8288
}

src/page.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import * as types from './types';
2727
import { Events } from './events';
2828
import { BrowserContext } from './browserContext';
2929
import { ConsoleMessage, ConsoleMessageLocation } from './console';
30-
import Injected from './injected/injected';
3130
import * as accessibility from './accessibility';
3231
import * as platform from './platform';
3332

@@ -197,13 +196,6 @@ export class Page extends platform.EventEmitter {
197196
return this.mainFrame().waitForSelector(selector, options);
198197
}
199198

200-
async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> {
201-
const mainContext = await this.mainFrame()._mainContext();
202-
return mainContext.evaluate((injected: Injected, target: Element, name: string) => {
203-
return injected.engines.get(name)!.create(document.documentElement, target);
204-
}, await mainContext._injected(), handle, name);
205-
}
206-
207199
evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => {
208200
return this.mainFrame().evaluateHandle(pageFunction, ...args as any);
209201
}

src/selectors.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as zsSelectorEngineSource from './generated/zsSelectorEngineSource';
18+
import * as dom from './dom';
19+
import Injected from './injected/injected';
20+
21+
let selectors: Selectors;
22+
23+
export class Selectors {
24+
readonly _sources: string[];
25+
_generation = 0;
26+
27+
static _instance() {
28+
if (!selectors)
29+
selectors = new Selectors();
30+
return selectors;
31+
}
32+
33+
constructor() {
34+
this._sources = [zsSelectorEngineSource.source];
35+
}
36+
37+
async register(engineSource: string) {
38+
this._sources.push(engineSource);
39+
++this._generation;
40+
}
41+
42+
async _createSelector(name: string, handle: dom.ElementHandle<Element>): Promise<string | undefined> {
43+
const mainContext = await handle._page.mainFrame()._mainContext();
44+
return mainContext.evaluate((injected: Injected, target: Element, name: string) => {
45+
return injected.engines.get(name)!.create(document.documentElement, target);
46+
}, await mainContext._injected(), handle, name);
47+
}
48+
}

test/playwright.spec.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => {
3636
const LINUX = os.platform() === 'linux';
3737
const WIN = os.platform() === 'win32';
3838

39-
const playwright = require(playwrightPath)[product.toLowerCase()];
39+
const playwrightModule = require(playwrightPath);
40+
const playwright = playwrightModule[product.toLowerCase()];
4041

4142
const headless = (process.env.HEADLESS || 'true').trim().toLowerCase() === 'true';
4243
const slowMo = parseInt((process.env.SLOW_MO || '0').trim(), 10);
@@ -81,6 +82,7 @@ module.exports.describe = ({testRunner, product, playwrightPath}) => {
8182
LINUX,
8283
WIN,
8384
playwright,
85+
selectors: playwrightModule.selectors,
8486
expect,
8587
defaultBrowserOptions,
8688
playwrightPath,

test/queryselector.spec.js

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18-
module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM, WEBKIT}) {
18+
module.exports.describe = function({testRunner, expect, selectors, FFOX, CHROMIUM, WEBKIT}) {
1919
const {describe, xdescribe, fdescribe} = testRunner;
2020
const {it, fit, xit, dit} = testRunner;
2121
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
@@ -393,28 +393,28 @@ module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM,
393393

394394
it('create', async ({page}) => {
395395
await page.setContent(`<div>yo</div><div>ya</div><div>ya</div>`);
396-
expect(await page._createSelector('zs', await page.$('div'))).toBe('"yo"');
397-
expect(await page._createSelector('zs', await page.$('div:nth-child(2)'))).toBe('"ya"');
398-
expect(await page._createSelector('zs', await page.$('div:nth-child(3)'))).toBe('"ya"#1');
396+
expect(await selectors._createSelector('zs', await page.$('div'))).toBe('"yo"');
397+
expect(await selectors._createSelector('zs', await page.$('div:nth-child(2)'))).toBe('"ya"');
398+
expect(await selectors._createSelector('zs', await page.$('div:nth-child(3)'))).toBe('"ya"#1');
399399

400400
await page.setContent(`<img alt="foo bar">`);
401-
expect(await page._createSelector('zs', await page.$('img'))).toBe('img[alt="foo bar"]');
401+
expect(await selectors._createSelector('zs', await page.$('img'))).toBe('img[alt="foo bar"]');
402402

403403
await page.setContent(`<div>yo<span></span></div><span></span>`);
404-
expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"~SPAN');
405-
expect(await page._createSelector('zs', await page.$('span:nth-child(2)'))).toBe('SPAN#1');
404+
expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"~SPAN');
405+
expect(await selectors._createSelector('zs', await page.$('span:nth-child(2)'))).toBe('SPAN#1');
406406
});
407407

408408
it('children of various display parents', async ({page}) => {
409409
await page.setContent(`<body><div style='position: fixed;'><span>yo</span></div></body>`);
410-
expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"');
410+
expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"');
411411

412412
await page.setContent(`<div style='position: relative;'><span>yo</span></div>`);
413-
expect(await page._createSelector('zs', await page.$('span'))).toBe('"yo"');
413+
expect(await selectors._createSelector('zs', await page.$('span'))).toBe('"yo"');
414414

415415
// "display: none" makes all children text invisible - fallback to tag name.
416416
await page.setContent(`<div style='display: none;'><span>yo</span></div>`);
417-
expect(await page._createSelector('zs', await page.$('span'))).toBe('SPAN');
417+
expect(await selectors._createSelector('zs', await page.$('span'))).toBe('SPAN');
418418
});
419419

420420
it('boundary', async ({page}) => {
@@ -465,7 +465,7 @@ module.exports.describe = function({testRunner, expect, product, FFOX, CHROMIUM,
465465
<div id=target2>hello</div>
466466
</div>
467467
</div>`);
468-
expect(await page._createSelector('zs', await page.$('#target'))).toBe('"ya"~"hey"~"hello"');
468+
expect(await selectors._createSelector('zs', await page.$('#target'))).toBe('"ya"~"hey"~"hello"');
469469
expect(await page.$eval(`zs="ya"~"hey"~"hello"`, e => e.outerHTML)).toBe('<div id="target">hello</div>');
470470
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""');
471471
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,
498498

499499
it('create', async ({page}) => {
500500
await page.setContent(`<div>yo</div><div>"ya</div><div>ye ye</div>`);
501-
expect(await page._createSelector('text', await page.$('div'))).toBe('yo');
502-
expect(await page._createSelector('text', await page.$('div:nth-child(2)'))).toBe('"\\"ya"');
503-
expect(await page._createSelector('text', await page.$('div:nth-child(3)'))).toBe('"ye ye"');
501+
expect(await selectors._createSelector('text', await page.$('div'))).toBe('yo');
502+
expect(await selectors._createSelector('text', await page.$('div:nth-child(2)'))).toBe('"\\"ya"');
503+
expect(await selectors._createSelector('text', await page.$('div:nth-child(3)'))).toBe('"ye ye"');
504504

505505
await page.setContent(`<div>yo</div><div>yo<div>ya</div>hey</div>`);
506-
expect(await page._createSelector('text', await page.$('div:nth-child(2)'))).toBe('hey');
506+
expect(await selectors._createSelector('text', await page.$('div:nth-child(2)'))).toBe('hey');
507507

508508
await page.setContent(`<div> yo <div></div>ya</div>`);
509-
expect(await page._createSelector('text', await page.$('div'))).toBe('yo');
509+
expect(await selectors._createSelector('text', await page.$('div'))).toBe('yo');
510510

511511
await page.setContent(`<div> "yo <div></div>ya</div>`);
512-
expect(await page._createSelector('text', await page.$('div'))).toBe('" \\"yo "');
512+
expect(await selectors._createSelector('text', await page.$('div'))).toBe('" \\"yo "');
513+
});
514+
});
515+
516+
describe('selectors.register', () => {
517+
it('should work', async ({page}) => {
518+
const createTagSelector = () => ({
519+
name: 'tag',
520+
create(root, target) {
521+
return target.nodeName;
522+
},
523+
query(root, selector) {
524+
return root.querySelector(selector);
525+
},
526+
queryAll(root, selector) {
527+
return Array.from(root.querySelectorAll(selector));
528+
}
529+
});
530+
await selectors.register(`(${createTagSelector.toString()})()`);
531+
await page.setContent('<div><span></span></div><div></div>');
532+
expect(await selectors._createSelector('tag', await page.$('div'))).toBe('DIV');
533+
expect(await page.$eval('tag=DIV', e => e.nodeName)).toBe('DIV');
534+
expect(await page.$eval('tag=SPAN', e => e.nodeName)).toBe('SPAN');
535+
expect(await page.$$eval('tag=DIV', es => es.length)).toBe(2);
536+
});
537+
it('should update', async ({page}) => {
538+
await page.setContent('<div><dummy id=d1></dummy></div><span><dummy id=d2></dummy></span>');
539+
expect(await page.$eval('div', e => e.nodeName)).toBe('DIV');
540+
const error = await page.$('dummy=foo').catch(e => e);
541+
expect(error.message).toContain('Unknown engine dummy while parsing selector dummy=foo');
542+
await selectors.register(`
543+
({
544+
name: 'dummy',
545+
create(root, target) {
546+
return target.nodeName;
547+
},
548+
query(root, selector) {
549+
return root.querySelector('dummy');
550+
},
551+
queryAll(root, selector) {
552+
return Array.from(root.querySelectorAll('dummy'));
553+
}
554+
})
555+
`);
556+
expect(await page.$eval('dummy=foo', e => e.id)).toBe('d1');
557+
expect(await page.$eval('css=span >> dummy=foo', e => e.id)).toBe('d2');
513558
});
514559
});
515560
};

0 commit comments

Comments
 (0)