Skip to content

Commit f9f5fd0

Browse files
authored
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".
1 parent f58d909 commit f9f5fd0

File tree

6 files changed

+80
-15
lines changed

6 files changed

+80
-15
lines changed

docs/core-concepts.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,13 @@ await page.click('css:light=div');
168168
Selectors using the same or different engines can be combined using the `>>` separator. For example,
169169

170170
```js
171-
await page.click('#free-month-promo >> text=Learn more');
171+
// Click an element with text 'Sign Up' inside of a #free-month-promo.
172+
await page.click('#free-month-promo >> text=Sign Up');
173+
```
174+
175+
```js
176+
// Capture textContent of a section that contains an element with text 'Selectors'.
177+
const sectionText = await page.$eval('*css=section >> text=Selectors', e => e.textContent);
172178
```
173179

174180
<br/>

docs/selectors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ document
2222
.querySelector('span[attr=value]')
2323
```
2424

25+
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`.
26+
2527
For convenience, selectors in the wrong format are heuristically converted to the right format:
2628
- Selector starting with `//` is assumed to be `xpath=selector`. Example: `page.click('//html')` is converted to `page.click('xpath=//html')`.
2729
- Selector surrounded with quotes (either `"` or `'`) is assumed to be `text=selector`. Example: `page.click('"foo"')` is converted to `page.click('text="foo"')`.

src/injected/selectorEvaluator.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,27 @@ class SelectorEvaluator {
5555
}
5656

5757
private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined {
58-
const current = selector[index];
59-
if (index === selector.length - 1)
58+
const current = selector.parts[index];
59+
if (index === selector.parts.length - 1)
6060
return this.engines.get(current.name)!.query(root, current.body);
6161
const all = this.engines.get(current.name)!.queryAll(root, current.body);
6262
for (const next of all) {
6363
const result = this._querySelectorRecursively(next, selector, index + 1);
6464
if (result)
65-
return result;
65+
return selector.capture === index ? next : result;
6666
}
6767
}
6868

6969
querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
7070
if (!(root as any)['querySelectorAll'])
7171
throw new Error('Node is not queryable.');
72+
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
73+
// Query all elements up to the capture.
74+
const partsToQuerAll = selector.parts.slice(0, capture + 1);
75+
// Check they have a descendant matching everything after the capture.
76+
const partsToCheckOne = selector.parts.slice(capture + 1);
7277
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
73-
for (const { name, body } of selector) {
78+
for (const { name, body } of partsToQuerAll) {
7479
const newSet = new Set<Element>();
7580
for (const prev of set) {
7681
for (const next of this.engines.get(name)!.queryAll(prev, body)) {
@@ -81,7 +86,11 @@ class SelectorEvaluator {
8186
}
8287
set = newSet;
8388
}
84-
return Array.from(set) as Element[];
89+
const candidates = Array.from(set) as Element[];
90+
if (!partsToCheckOne.length)
91+
return candidates;
92+
const partial = { parts: partsToCheckOne };
93+
return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0));
8594
}
8695
}
8796

src/selectors.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export class Selectors {
6262
}
6363

6464
private _needsMainContext(parsed: types.ParsedSelector): boolean {
65-
return parsed.some(({name}) => {
65+
return parsed.parts.some(({name}) => {
6666
const custom = this._engines.get(name);
6767
return custom ? !custom.contentScript : false;
6868
});
@@ -188,13 +188,13 @@ export class Selectors {
188188
let index = 0;
189189
let quote: string | undefined;
190190
let start = 0;
191-
const result: types.ParsedSelector = [];
191+
const result: types.ParsedSelector = { parts: [] };
192192
const append = () => {
193193
const part = selector.substring(start, index).trim();
194194
const eqIndex = part.indexOf('=');
195195
let name: string;
196196
let body: string;
197-
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:]+$/)) {
197+
if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) {
198198
name = part.substring(0, eqIndex).trim();
199199
body = part.substring(eqIndex + 1);
200200
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
@@ -213,9 +213,19 @@ export class Selectors {
213213
body = part;
214214
}
215215
name = name.toLowerCase();
216+
let capture = false;
217+
if (name[0] === '*') {
218+
capture = true;
219+
name = name.substring(1);
220+
}
216221
if (!this._builtinEngines.has(name) && !this._engines.has(name))
217-
throw new Error(`Unknown engine ${name} while parsing selector ${selector}`);
218-
result.push({ name, body });
222+
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
223+
result.parts.push({ name, body });
224+
if (capture) {
225+
if (result.capture !== undefined)
226+
throw new Error(`Only one of the selectors can capture using * modifier`);
227+
result.capture = result.parts.length - 1;
228+
}
219229
};
220230
while (index < selector.length) {
221231
const c = selector[index];

src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ export type JSCoverageOptions = {
157157
};
158158

159159
export type ParsedSelector = {
160-
name: string,
161-
body: string,
162-
}[];
160+
parts: {
161+
name: string,
162+
body: string,
163+
}[],
164+
capture?: number,
165+
};

test/queryselector.spec.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,21 @@ describe('Page.$eval', function() {
111111
const html = await page.$eval('button >> "Next"', e => e.outerHTML);
112112
expect(html).toBe('<button>Next</button>');
113113
});
114+
it('should support * capture', async({page, server}) => {
115+
await page.setContent('<section><div><span>a</span></div></section><section><div><span>b</span></div></section>');
116+
expect(await page.$eval('*css=div >> "b"', e => e.outerHTML)).toBe('<div><span>b</span></div>');
117+
expect(await page.$eval('section >> *css=div >> "b"', e => e.outerHTML)).toBe('<div><span>b</span></div>');
118+
expect(await page.$eval('css=div >> *text="b"', e => e.outerHTML)).toBe('<span>b</span>');
119+
expect(await page.$('*')).toBeTruthy();
120+
});
121+
it('should throw on multiple * captures', async({page, server}) => {
122+
const error = await page.$eval('*css=div >> *css=span', e => e.outerHTML).catch(e => e);
123+
expect(error.message).toBe('Only one of the selectors can capture using * modifier');
124+
});
125+
it('should throw on malformed * capture', async({page, server}) => {
126+
const error = await page.$eval('*=div', e => e.outerHTML).catch(e => e);
127+
expect(error.message).toBe('Unknown engine "" while parsing selector *=div');
128+
});
114129
});
115130

116131
describe('Page.$$eval', function() {
@@ -139,6 +154,26 @@ describe('Page.$$eval', function() {
139154
const spansCount = await page.$$eval('css=div >> css=span', spans => spans.length);
140155
expect(spansCount).toBe(3);
141156
});
157+
it('should support * capture', async({page, server}) => {
158+
await page.setContent('<section><div><span>a</span></div></section><section><div><span>b</span></div></section>');
159+
expect(await page.$$eval('*css=div >> "b"', els => els.length)).toBe(1);
160+
expect(await page.$$eval('section >> *css=div >> "b"', els => els.length)).toBe(1);
161+
expect(await page.$$eval('section >> *', els => els.length)).toBe(4);
162+
163+
await page.setContent('<section><div><span>a</span><span>a</span></div></section>');
164+
expect(await page.$$eval('*css=div >> "a"', els => els.length)).toBe(1);
165+
expect(await page.$$eval('section >> *css=div >> "a"', els => els.length)).toBe(1);
166+
167+
await page.setContent('<div><span>a</span></div><div><span>a</span></div><section><div><span>a</span></div></section>');
168+
expect(await page.$$eval('*css=div >> "a"', els => els.length)).toBe(3);
169+
expect(await page.$$eval('section >> *css=div >> "a"', els => els.length)).toBe(1);
170+
});
171+
it('should support * capture when multiple paths match', async({page, server}) => {
172+
await page.setContent('<div><div><span></span></div></div><div></div>');
173+
expect(await page.$$eval('*css=div >> span', els => els.length)).toBe(2);
174+
await page.setContent('<div><div><span></span></div><span></span><span></span></div><div></div>');
175+
expect(await page.$$eval('*css=div >> span', els => els.length)).toBe(2);
176+
});
142177
});
143178

144179
describe('Page.$', function() {
@@ -710,7 +745,7 @@ describe('selectors.register', () => {
710745
expect(await page.$eval('div', e => e.nodeName)).toBe('DIV');
711746

712747
let error = await page.$('dummy=ignored').catch(e => e);
713-
expect(error.message).toContain('Unknown engine dummy while parsing selector dummy=ignored');
748+
expect(error.message).toBe('Unknown engine "dummy" while parsing selector dummy=ignored');
714749

715750
const createDummySelector = () => ({
716751
create(root, target) {

0 commit comments

Comments
 (0)