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:
parent
55b9d14bbd
commit
f587a43932
|
|
@ -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-%%
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
32
packages/playwright-core/types/types.d.ts
vendored
32
packages/playwright-core/types/types.d.ts
vendored
|
|
@ -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>`.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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!"');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue