feat(locator): "has" option (#11411)

This introduces `locator('div', { has: locator })` syntax that matches elements containing other elements.
Can be used together with `hasText`.

Internally, has selector engine takes an inner selector escaped with double-quotes:
`div >> has="li >> span >> text=Foo" >> span`.
This commit is contained in:
Dmitry Gozman 2022-02-02 16:55:50 -08:00 committed by GitHub
parent 55b9d14bbd
commit f587a43932
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 195 additions and 20 deletions

View file

@ -876,5 +876,14 @@ Slows down Playwright operations by the specified amount of milliseconds. Useful
Matches elements containing specified text somewhere inside, possibly in a child or a descendant element.
For example, `"Playwright"` matches `<article><div>Playwright</div></article>`.
## locator-option-has
- `has` <[Locator]>
Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer one.
For example, `article` that has `text=Playwright` matches `<article><div>Playwright</div></article>`.
Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
## locator-options-list
- %%-locator-option-has-text-%%
- %%-locator-option-has-%%

View file

@ -288,7 +288,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
return await this._channel.highlight({ selector });
}
locator(selector: string, options?: { hasText?: string | RegExp }): Locator {
locator(selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator {
return new Locator(this, selector, options);
}

View file

@ -29,7 +29,7 @@ export class Locator implements api.Locator {
private _frame: Frame;
private _selector: string;
constructor(frame: Frame, selector: string, options?: { hasText?: string | RegExp }) {
constructor(frame: Frame, selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
this._frame = frame;
this._selector = selector;
@ -40,6 +40,12 @@ export class Locator implements api.Locator {
else
this._selector += ` >> :scope:has-text(${escapeWithQuotes(text, '"')})`;
}
if (options?.has) {
if (options.has._frame !== frame)
throw new Error(`Inner "has" locator must belong to the same frame.`);
this._selector += ` >> has=` + JSON.stringify(options.has._selector);
}
}
private async _withElement<R>(task: (handle: ElementHandle<SVGElement | HTMLElement>, timeout?: number) => Promise<R>, timeout?: number): Promise<R> {
@ -110,7 +116,7 @@ export class Locator implements api.Locator {
return this._frame._highlight(this._selector);
}
locator(selector: string, options?: { hasText?: string | RegExp }): Locator {
locator(selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator {
return new Locator(this._frame, this._selector + ' >> ' + selector, options);
}

View file

@ -515,7 +515,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return this._mainFrame.fill(selector, value, options);
}
locator(selector: string, options?: { hasText?: string | RegExp }): Locator {
locator(selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator {
return this.mainFrame().locator(selector, options);
}

View file

@ -19,7 +19,7 @@ export { InvalidSelectorError, isInvalidSelectorError } from './cssParser';
export type ParsedSelectorPart = {
name: string,
body: string | CSSComplexSelectorList,
body: string | CSSComplexSelectorList | ParsedSelector,
source: string,
};
@ -34,6 +34,7 @@ type ParsedSelectorStrings = {
};
export const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'has-text', 'above', 'below', 'right-of', 'left-of', 'near', 'nth-match']);
const kNestedSelectorNames = new Set(['has']);
export function parseSelector(selector: string): ParsedSelector {
const result = parseSelectorString(selector);
@ -48,8 +49,25 @@ export function parseSelector(selector: string): ParsedSelector {
source: part.body
};
}
if (kNestedSelectorNames.has(part.name)) {
let innerSelector: string;
try {
const unescaped = JSON.parse(part.body);
if (typeof unescaped !== 'string')
throw new Error(`Malformed selector: ${part.name}=` + part.body);
innerSelector = unescaped;
} catch (e) {
throw new Error(`Malformed selector: ${part.name}=` + part.body);
}
const result = { name: part.name, source: part.body, body: parseSelector(innerSelector) };
if (result.body.parts.some(part => part.name === 'control' && part.body === 'enter-frame'))
throw new Error(`Frames are not allowed inside "${part.name}" selectors`);
return result;
}
return { ...part, source: part.body };
});
if (kNestedSelectorNames.has(parts[0].name))
throw new Error(`"${parts[0].name}" selector cannot be first`);
return {
capture: result.capture,
parts
@ -94,6 +112,19 @@ export function stringifySelector(selector: string | ParsedSelector): string {
}).join(' >> ');
}
export function allEngineNames(selector: ParsedSelector): Set<string> {
const result = new Set<string>();
const visit = (selector: ParsedSelector) => {
for (const part of selector.parts) {
result.add(part.name);
if (kNestedSelectorNames.has(part.name))
visit(part.body as ParsedSelector);
}
};
visit(selector);
return result;
}
function parseSelectorString(selector: string): ParsedSelectorStrings {
let index = 0;
let quote: string | undefined;

View file

@ -18,7 +18,7 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ReactEngine } from './reactSelectorEngine';
import { VueEngine } from './vueSelectorEngine';
import { ParsedSelector, ParsedSelectorPart, parseSelector, stringifySelector } from '../common/selectorParser';
import { allEngineNames, ParsedSelector, ParsedSelectorPart, parseSelector, stringifySelector } from '../common/selectorParser';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
import { CSSComplexSelectorList } from '../common/cssParser';
import { generateSelector } from './selectorGenerator';
@ -99,6 +99,7 @@ export class InjectedScript {
this._engines.set('nth', { queryAll: () => [] });
this._engines.set('visible', { queryAll: () => [] });
this._engines.set('control', this._createControlEngine());
this._engines.set('has', this._createHasEngine());
for (const { name, engine } of customEngines)
this._engines.set(name, engine);
@ -116,9 +117,9 @@ export class InjectedScript {
parseSelector(selector: string): ParsedSelector {
const result = parseSelector(selector);
for (const part of result.parts) {
if (!this._engines.has(part.name))
throw this.createStacklessError(`Unknown engine "${part.name}" while parsing selector ${selector}`);
for (const name of allEngineNames(result)) {
if (!this._engines.has(name))
throw this.createStacklessError(`Unknown engine "${name}" while parsing selector ${selector}`);
}
return result;
}
@ -181,7 +182,7 @@ export class InjectedScript {
}
let all = queryResults[index];
if (!all) {
all = this._queryEngineAll(selector.parts[index], root.element);
all = this._queryEngineAll(part, root.element);
queryResults[index] = all;
}
@ -278,6 +279,16 @@ export class InjectedScript {
};
}
private _createHasEngine(): SelectorEngineV2 {
const queryAll = (root: SelectorRoot, body: ParsedSelector) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
const has = !!this.querySelector(body, root, false);
return has ? [root as Element] : [];
};
return { queryAll };
}
extend(source: string, params: any): any {
const constrFunction = global.eval(`
(() => {

View file

@ -18,7 +18,7 @@ import * as dom from './dom';
import * as frames from './frames';
import * as js from './javascript';
import * as types from './types';
import { InvalidSelectorError, ParsedSelector, parseSelector, stringifySelector } from './common/selectorParser';
import { allEngineNames, InvalidSelectorError, ParsedSelector, parseSelector, stringifySelector } from './common/selectorParser';
import { createGuid } from '../utils/utils';
export type SelectorInfo = {
@ -44,7 +44,7 @@ export class Selectors {
'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light',
'nth', 'visible', 'control'
'nth', 'visible', 'control', 'has',
]);
this._builtinEnginesInMainWorld = new Set([
'_react', '_vue',
@ -135,13 +135,13 @@ export class Selectors {
parseSelector(selector: string | ParsedSelector, strict: boolean): SelectorInfo {
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
let needsMainWorld = false;
for (const part of parsed.parts) {
const custom = this._engines.get(part.name);
if (!custom && !this._builtinEngines.has(part.name))
throw new InvalidSelectorError(`Unknown engine "${part.name}" while parsing selector ${stringifySelector(parsed)}`);
for (const name of allEngineNames(parsed)) {
const custom = this._engines.get(name);
if (!custom && !this._builtinEngines.has(name))
throw new InvalidSelectorError(`Unknown engine "${name}" while parsing selector ${stringifySelector(parsed)}`);
if (custom && !custom.contentScript)
needsMainWorld = true;
if (this._builtinEnginesInMainWorld.has(part.name))
if (this._builtinEnginesInMainWorld.has(name))
needsMainWorld = true;
}
return {

View file

@ -24,7 +24,7 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?
element: Element | undefined;
elements: Element[];
constructor(selector: string, options?: { hasText?: string | RegExp }) {
constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) {
this.selector = selector;
if (options?.hasText) {
const text = options.hasText;
@ -33,12 +33,14 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?
else
this.selector += ` >> :scope:has-text(${escapeWithQuotes(text)})`;
}
if (options?.has)
this.selector += ` >> has=` + JSON.stringify(options.has.selector);
const parsed = injectedScript.parseSelector(this.selector);
this.element = injectedScript.querySelector(parsed, document, false);
this.elements = injectedScript.querySelectorAll(parsed, document);
}
locator(selector: string, options?: { hasText: string | RegExp }): Locator {
locator(selector: string, options?: { hasText: string | RegExp, has?: Locator }): Locator {
return new Locator(this.selector ? this.selector + ' >> ' + selector : selector, options);
}
}
@ -48,7 +50,7 @@ function createLocator(injectedScript: InjectedScript, initial: string, options?
type ConsoleAPIInterface = {
$: (selector: string) => void;
$$: (selector: string) => void;
locator: (selector: string) => any;
locator: (selector: string, options?: { hasText: string | RegExp, has?: any }) => any;
inspect: (selector: string) => void;
selector: (element: Element) => void;
resume: () => void;

View file

@ -2570,6 +2570,14 @@ export interface Page {
* @param options
*/
locator(selector: string, options?: {
/**
* Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer one.
* For example, `article` that has `text=Playwright` matches `<article><div>Playwright</div></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
has?: Locator;
/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. For example,
* `"Playwright"` matches `<article><div>Playwright</div></article>`.
@ -5353,6 +5361,14 @@ export interface Frame {
* @param options
*/
locator(selector: string, options?: {
/**
* Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer one.
* For example, `article` that has `text=Playwright` matches `<article><div>Playwright</div></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
has?: Locator;
/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. For example,
* `"Playwright"` matches `<article><div>Playwright</div></article>`.
@ -9266,6 +9282,14 @@ export interface Locator {
* @param options
*/
locator(selector: string, options?: {
/**
* Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer one.
* For example, `article` that has `text=Playwright` matches `<article><div>Playwright</div></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
has?: Locator;
/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. For example,
* `"Playwright"` matches `<article><div>Playwright</div></article>`.
@ -13700,6 +13724,14 @@ export interface FrameLocator {
* @param options
*/
locator(selector: string, options?: {
/**
* Matches elements containing an element that matches an inner locator. Inner locator is queried against the outer one.
* For example, `article` that has `text=Playwright` matches `<article><div>Playwright</div></article>`.
*
* Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s.
*/
has?: Locator;
/**
* Matches elements containing specified text somewhere inside, possibly in a child or a descendant element. For example,
* `"Playwright"` matches `<article><div>Playwright</div></article>`.

View file

@ -60,3 +60,9 @@ it('should support playwright.locator.values', async ({ page }) => {
expect(await page.evaluate(`playwright.locator('div', { hasText: /ELL/i }).elements.length`)).toBe(1);
expect(await page.evaluate(`playwright.locator('div', { hasText: /Hello/ }).elements.length`)).toBe(1);
});
it('should support playwright.locator({ has })', async ({ page }) => {
await page.setContent('<div>Hi</div><div><span>Hello</span></div>');
expect(await page.evaluate(`playwright.locator('div', { has: playwright.locator('span') }).element.innerHTML`)).toContain('Hello');
expect(await page.evaluate(`playwright.locator('div', { has: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('span');
});

View file

@ -89,3 +89,47 @@ it('should filter by regex and regexp flags', async ({ page }) => {
await page.setContent(`<div>Hello "world"</div><div>Hello world</div>`);
await expect(page.locator('div', { hasText: /hElLo "world"/i })).toHaveText('Hello "world"');
});
it('should support has:locator', async ({ page }) => {
await page.setContent(`<div><span>hello</span></div><div><span>world</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`text=world`)
})).toHaveCount(1);
expect(await page.locator(`div`, {
has: page.locator(`text=world`)
}).evaluate(e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`text="hello"`)
})).toHaveCount(1);
expect(await page.locator(`div`, {
has: page.locator(`text="hello"`)
}).evaluate(e => e.outerHTML)).toBe(`<div><span>hello</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`xpath=./span`)
})).toHaveCount(2);
await expect(page.locator(`div`, {
has: page.locator(`span`)
})).toHaveCount(2);
await expect(page.locator(`div`, {
has: page.locator(`span`, { hasText: 'wor' })
})).toHaveCount(1);
expect(await page.locator(`div`, {
has: page.locator(`span`, { hasText: 'wor' })
}).evaluate(e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
await expect(page.locator(`div`, {
has: page.locator(`span`),
hasText: 'wor',
})).toHaveCount(1);
});
it('should enforce same frame for has:locator', async ({ page, server }) => {
await page.goto(server.PREFIX + '/frames/two-frames.html');
const child = page.frames()[1];
let error;
try {
page.locator('div', { has: child.locator('span') });
} catch (e) {
error = e;
}
expect(error.message).toContain('Inner "has" locator must belong to the same frame.');
});

View file

@ -339,3 +339,37 @@ it('should properly determine visibility of display:contents elements', async ({
</div>`);
await page.waitForSelector('article', { state: 'hidden' });
});
it('should work with has=', async ({ page, server }) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
expect(await page.$$eval(`div >> has="#target"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> has="[data-testid=foo]"`, els => els.length)).toBe(3);
expect(await page.$$eval(`div >> has="[attr*=value]"`, els => els.length)).toBe(2);
await page.setContent(`<section><span></span><div></div></section><section><br></section>`);
expect(await page.$$eval(`section >> has="span, div"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> has="span, div"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> has="br"`, els => els.length)).toBe(1);
expect(await page.$$eval(`section >> has="span, br"`, els => els.length)).toBe(2);
expect(await page.$$eval(`section >> has="span, br, div"`, els => els.length)).toBe(2);
await page.setContent(`<div><span>hello</span></div><div><span>world</span></div>`);
expect(await page.$$eval(`div >> has="text=world"`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> has="text=world"`, e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
expect(await page.$$eval(`div >> has="text=\\"hello\\""`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> has="text=\\"hello\\""`, e => e.outerHTML)).toBe(`<div><span>hello</span></div>`);
expect(await page.$$eval(`div >> has="xpath=./span"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> has="span"`, els => els.length)).toBe(2);
expect(await page.$$eval(`div >> has="span >> text=wor"`, els => els.length)).toBe(1);
expect(await page.$eval(`div >> has="span >> text=wor"`, e => e.outerHTML)).toBe(`<div><span>world</span></div>`);
expect(await page.$eval(`div >> has="span >> text=wor" >> span`, e => e.outerHTML)).toBe(`<span>world</span>`);
const error1 = await page.$(`div >> has=abc`).catch(e => e);
expect(error1.message).toContain('Malformed selector: has=abc');
const error2 = await page.$(`has="div"`).catch(e => e);
expect(error2.message).toContain('"has" selector cannot be first');
const error3 = await page.$(`div >> has=33`).catch(e => e);
expect(error3.message).toContain('Malformed selector: has=33');
const error4 = await page.$(`div >> has="span!"`).catch(e => e);
expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"');
});