feat(selectors): allow to capture intermediate result (#1978)
This introduces the `*name=body` syntax to capture intermediate result. For example, `*css=section >> "Title"` will capture a section that contains "Title".
This commit is contained in:
parent
f58d909db4
commit
f9f5fd03b0
|
|
@ -168,7 +168,13 @@ await page.click('css:light=div');
|
||||||
Selectors using the same or different engines can be combined using the `>>` separator. For example,
|
Selectors using the same or different engines can be combined using the `>>` separator. For example,
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await page.click('#free-month-promo >> text=Learn more');
|
// Click an element with text 'Sign Up' inside of a #free-month-promo.
|
||||||
|
await page.click('#free-month-promo >> text=Sign Up');
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Capture textContent of a section that contains an element with text 'Selectors'.
|
||||||
|
const sectionText = await page.$eval('*css=section >> text=Selectors', e => e.textContent);
|
||||||
```
|
```
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ document
|
||||||
.querySelector('span[attr=value]')
|
.querySelector('span[attr=value]')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Selector engine name can be prefixed with `*` to capture element that matches the particular clause instead of the last one. For example, `css=article >> text=Hello` captures the element with the text `Hello`, and `*css=article >> text=Hello` (note the `*`) captures the `article` element that contains some element with the text `Hello`.
|
||||||
|
|
||||||
For convenience, selectors in the wrong format are heuristically converted to the right format:
|
For convenience, selectors in the wrong format are heuristically converted to the right format:
|
||||||
- Selector starting with `//` is assumed to be `xpath=selector`. Example: `page.click('//html')` is converted to `page.click('xpath=//html')`.
|
- Selector starting with `//` is assumed to be `xpath=selector`. Example: `page.click('//html')` is converted to `page.click('xpath=//html')`.
|
||||||
- Selector surrounded with quotes (either `"` or `'`) is assumed to be `text=selector`. Example: `page.click('"foo"')` is converted to `page.click('text="foo"')`.
|
- Selector surrounded with quotes (either `"` or `'`) is assumed to be `text=selector`. Example: `page.click('"foo"')` is converted to `page.click('text="foo"')`.
|
||||||
|
|
|
||||||
|
|
@ -55,22 +55,27 @@ class SelectorEvaluator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined {
|
private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined {
|
||||||
const current = selector[index];
|
const current = selector.parts[index];
|
||||||
if (index === selector.length - 1)
|
if (index === selector.parts.length - 1)
|
||||||
return this.engines.get(current.name)!.query(root, current.body);
|
return this.engines.get(current.name)!.query(root, current.body);
|
||||||
const all = this.engines.get(current.name)!.queryAll(root, current.body);
|
const all = this.engines.get(current.name)!.queryAll(root, current.body);
|
||||||
for (const next of all) {
|
for (const next of all) {
|
||||||
const result = this._querySelectorRecursively(next, selector, index + 1);
|
const result = this._querySelectorRecursively(next, selector, index + 1);
|
||||||
if (result)
|
if (result)
|
||||||
return result;
|
return selector.capture === index ? next : result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
|
querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
|
||||||
if (!(root as any)['querySelectorAll'])
|
if (!(root as any)['querySelectorAll'])
|
||||||
throw new Error('Node is not queryable.');
|
throw new Error('Node is not queryable.');
|
||||||
|
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
|
||||||
|
// Query all elements up to the capture.
|
||||||
|
const partsToQuerAll = selector.parts.slice(0, capture + 1);
|
||||||
|
// Check they have a descendant matching everything after the capture.
|
||||||
|
const partsToCheckOne = selector.parts.slice(capture + 1);
|
||||||
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
|
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
|
||||||
for (const { name, body } of selector) {
|
for (const { name, body } of partsToQuerAll) {
|
||||||
const newSet = new Set<Element>();
|
const newSet = new Set<Element>();
|
||||||
for (const prev of set) {
|
for (const prev of set) {
|
||||||
for (const next of this.engines.get(name)!.queryAll(prev, body)) {
|
for (const next of this.engines.get(name)!.queryAll(prev, body)) {
|
||||||
|
|
@ -81,7 +86,11 @@ class SelectorEvaluator {
|
||||||
}
|
}
|
||||||
set = newSet;
|
set = newSet;
|
||||||
}
|
}
|
||||||
return Array.from(set) as Element[];
|
const candidates = Array.from(set) as Element[];
|
||||||
|
if (!partsToCheckOne.length)
|
||||||
|
return candidates;
|
||||||
|
const partial = { parts: partsToCheckOne };
|
||||||
|
return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export class Selectors {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _needsMainContext(parsed: types.ParsedSelector): boolean {
|
private _needsMainContext(parsed: types.ParsedSelector): boolean {
|
||||||
return parsed.some(({name}) => {
|
return parsed.parts.some(({name}) => {
|
||||||
const custom = this._engines.get(name);
|
const custom = this._engines.get(name);
|
||||||
return custom ? !custom.contentScript : false;
|
return custom ? !custom.contentScript : false;
|
||||||
});
|
});
|
||||||
|
|
@ -188,13 +188,13 @@ export class Selectors {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let quote: string | undefined;
|
let quote: string | undefined;
|
||||||
let start = 0;
|
let start = 0;
|
||||||
const result: types.ParsedSelector = [];
|
const result: types.ParsedSelector = { parts: [] };
|
||||||
const append = () => {
|
const append = () => {
|
||||||
const part = selector.substring(start, index).trim();
|
const part = selector.substring(start, index).trim();
|
||||||
const eqIndex = part.indexOf('=');
|
const eqIndex = part.indexOf('=');
|
||||||
let name: string;
|
let name: string;
|
||||||
let body: string;
|
let body: string;
|
||||||
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:]+$/)) {
|
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) {
|
||||||
name = part.substring(0, eqIndex).trim();
|
name = part.substring(0, eqIndex).trim();
|
||||||
body = part.substring(eqIndex + 1);
|
body = part.substring(eqIndex + 1);
|
||||||
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
|
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
|
||||||
|
|
@ -213,9 +213,19 @@ export class Selectors {
|
||||||
body = part;
|
body = part;
|
||||||
}
|
}
|
||||||
name = name.toLowerCase();
|
name = name.toLowerCase();
|
||||||
|
let capture = false;
|
||||||
|
if (name[0] === '*') {
|
||||||
|
capture = true;
|
||||||
|
name = name.substring(1);
|
||||||
|
}
|
||||||
if (!this._builtinEngines.has(name) && !this._engines.has(name))
|
if (!this._builtinEngines.has(name) && !this._engines.has(name))
|
||||||
throw new Error(`Unknown engine ${name} while parsing selector ${selector}`);
|
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
|
||||||
result.push({ name, body });
|
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) {
|
while (index < selector.length) {
|
||||||
const c = selector[index];
|
const c = selector[index];
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,9 @@ export type JSCoverageOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedSelector = {
|
export type ParsedSelector = {
|
||||||
name: string,
|
parts: {
|
||||||
body: string,
|
name: string,
|
||||||
}[];
|
body: string,
|
||||||
|
}[],
|
||||||
|
capture?: number,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,21 @@ describe('Page.$eval', function() {
|
||||||
const html = await page.$eval('button >> "Next"', e => e.outerHTML);
|
const html = await page.$eval('button >> "Next"', e => e.outerHTML);
|
||||||
expect(html).toBe('<button>Next</button>');
|
expect(html).toBe('<button>Next</button>');
|
||||||
});
|
});
|
||||||
|
it('should support * capture', async({page, server}) => {
|
||||||
|
await page.setContent('<section><div><span>a</span></div></section><section><div><span>b</span></div></section>');
|
||||||
|
expect(await page.$eval('*css=div >> "b"', e => e.outerHTML)).toBe('<div><span>b</span></div>');
|
||||||
|
expect(await page.$eval('section >> *css=div >> "b"', e => e.outerHTML)).toBe('<div><span>b</span></div>');
|
||||||
|
expect(await page.$eval('css=div >> *text="b"', e => e.outerHTML)).toBe('<span>b</span>');
|
||||||
|
expect(await page.$('*')).toBeTruthy();
|
||||||
|
});
|
||||||
|
it('should throw on multiple * captures', async({page, server}) => {
|
||||||
|
const error = await page.$eval('*css=div >> *css=span', e => e.outerHTML).catch(e => e);
|
||||||
|
expect(error.message).toBe('Only one of the selectors can capture using * modifier');
|
||||||
|
});
|
||||||
|
it('should throw on malformed * capture', async({page, server}) => {
|
||||||
|
const error = await page.$eval('*=div', e => e.outerHTML).catch(e => e);
|
||||||
|
expect(error.message).toBe('Unknown engine "" while parsing selector *=div');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Page.$$eval', function() {
|
describe('Page.$$eval', function() {
|
||||||
|
|
@ -139,6 +154,26 @@ describe('Page.$$eval', function() {
|
||||||
const spansCount = await page.$$eval('css=div >> css=span', spans => spans.length);
|
const spansCount = await page.$$eval('css=div >> css=span', spans => spans.length);
|
||||||
expect(spansCount).toBe(3);
|
expect(spansCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
it('should support * capture', async({page, server}) => {
|
||||||
|
await page.setContent('<section><div><span>a</span></div></section><section><div><span>b</span></div></section>');
|
||||||
|
expect(await page.$$eval('*css=div >> "b"', els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval('section >> *css=div >> "b"', els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval('section >> *', els => els.length)).toBe(4);
|
||||||
|
|
||||||
|
await page.setContent('<section><div><span>a</span><span>a</span></div></section>');
|
||||||
|
expect(await page.$$eval('*css=div >> "a"', els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval('section >> *css=div >> "a"', els => els.length)).toBe(1);
|
||||||
|
|
||||||
|
await page.setContent('<div><span>a</span></div><div><span>a</span></div><section><div><span>a</span></div></section>');
|
||||||
|
expect(await page.$$eval('*css=div >> "a"', els => els.length)).toBe(3);
|
||||||
|
expect(await page.$$eval('section >> *css=div >> "a"', els => els.length)).toBe(1);
|
||||||
|
});
|
||||||
|
it('should support * capture when multiple paths match', async({page, server}) => {
|
||||||
|
await page.setContent('<div><div><span></span></div></div><div></div>');
|
||||||
|
expect(await page.$$eval('*css=div >> span', els => els.length)).toBe(2);
|
||||||
|
await page.setContent('<div><div><span></span></div><span></span><span></span></div><div></div>');
|
||||||
|
expect(await page.$$eval('*css=div >> span', els => els.length)).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Page.$', function() {
|
describe('Page.$', function() {
|
||||||
|
|
@ -710,7 +745,7 @@ describe('selectors.register', () => {
|
||||||
expect(await page.$eval('div', e => e.nodeName)).toBe('DIV');
|
expect(await page.$eval('div', e => e.nodeName)).toBe('DIV');
|
||||||
|
|
||||||
let error = await page.$('dummy=ignored').catch(e => e);
|
let error = await page.$('dummy=ignored').catch(e => e);
|
||||||
expect(error.message).toContain('Unknown engine dummy while parsing selector dummy=ignored');
|
expect(error.message).toBe('Unknown engine "dummy" while parsing selector dummy=ignored');
|
||||||
|
|
||||||
const createDummySelector = () => ({
|
const createDummySelector = () => ({
|
||||||
create(root, target) {
|
create(root, target) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue