Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(selectors): allow to capture intermediate result #1978

Merged
merged 1 commit into from
Apr 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,

```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/>
Expand Down
2 changes: 2 additions & 0 deletions docs/selectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ document
.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`.
dgozman marked this conversation as resolved.
Show resolved Hide resolved

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 surrounded with quotes (either `"` or `'`) is assumed to be `text=selector`. Example: `page.click('"foo"')` is converted to `page.click('text="foo"')`.
Expand Down
19 changes: 14 additions & 5 deletions src/injected/selectorEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,27 @@ class SelectorEvaluator {
}

private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined {
const current = selector[index];
if (index === selector.length - 1)
const current = selector.parts[index];
if (index === selector.parts.length - 1)
return this.engines.get(current.name)!.query(root, current.body);
const all = this.engines.get(current.name)!.queryAll(root, current.body);
for (const next of all) {
const result = this._querySelectorRecursively(next, selector, index + 1);
if (result)
return result;
return selector.capture === index ? next : result;
}
}

querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] {
if (!(root as any)['querySelectorAll'])
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 ]);
for (const { name, body } of selector) {
for (const { name, body } of partsToQuerAll) {
const newSet = new Set<Element>();
for (const prev of set) {
for (const next of this.engines.get(name)!.queryAll(prev, body)) {
Expand All @@ -81,7 +86,11 @@ class SelectorEvaluator {
}
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));
}
}

Expand Down
20 changes: 15 additions & 5 deletions src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export class Selectors {
}

private _needsMainContext(parsed: types.ParsedSelector): boolean {
return parsed.some(({name}) => {
return parsed.parts.some(({name}) => {
const custom = this._engines.get(name);
return custom ? !custom.contentScript : false;
});
Expand Down Expand Up @@ -188,13 +188,13 @@ export class Selectors {
let index = 0;
let quote: string | undefined;
let start = 0;
const result: types.ParsedSelector = [];
const result: types.ParsedSelector = { parts: [] };
const append = () => {
const part = selector.substring(start, index).trim();
const eqIndex = part.indexOf('=');
let name: 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();
body = part.substring(eqIndex + 1);
} else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') {
Expand All @@ -213,9 +213,19 @@ export class Selectors {
body = part;
}
name = name.toLowerCase();
let capture = false;
if (name[0] === '*') {
capture = true;
name = name.substring(1);
}
if (!this._builtinEngines.has(name) && !this._engines.has(name))
throw new Error(`Unknown engine ${name} while parsing selector ${selector}`);
result.push({ name, body });
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
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) {
const c = selector[index];
Expand Down
9 changes: 6 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export type JSCoverageOptions = {
};

export type ParsedSelector = {
name: string,
body: string,
}[];
parts: {
name: string,
body: string,
}[],
capture?: number,
};
37 changes: 36 additions & 1 deletion test/queryselector.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ describe('Page.$eval', function() {
const html = await page.$eval('button >> "Next"', e => e.outerHTML);
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() {
Expand Down Expand Up @@ -139,6 +154,26 @@ describe('Page.$$eval', function() {
const spansCount = await page.$$eval('css=div >> css=span', spans => spans.length);
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() {
Expand Down Expand Up @@ -690,7 +725,7 @@ describe('selectors.register', () => {
expect(await page.$eval('div', e => e.nodeName)).toBe('DIV');

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 = () => ({
create(root, target) {
Expand Down