diff --git a/docs/src/api/params.md b/docs/src/api/params.md
index 7718491d73..732af683f7 100644
--- a/docs/src/api/params.md
+++ b/docs/src/api/params.md
@@ -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 `Playwright
`.
+## 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 `Playwright
`.
+
+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-%%
diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts
index 1cba0f0157..32e2fcc021 100644
--- a/packages/playwright-core/src/client/frame.ts
+++ b/packages/playwright-core/src/client/frame.ts
@@ -288,7 +288,7 @@ export class Frame extends ChannelOwner 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);
}
diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts
index 9c0b46fae7..13b52c13ce 100644
--- a/packages/playwright-core/src/client/locator.ts
+++ b/packages/playwright-core/src/client/locator.ts
@@ -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(task: (handle: ElementHandle, timeout?: number) => Promise, timeout?: number): Promise {
@@ -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);
}
diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts
index 1dd7e8dd32..0558dbb8a7 100644
--- a/packages/playwright-core/src/client/page.ts
+++ b/packages/playwright-core/src/client/page.ts
@@ -515,7 +515,7 @@ export class Page extends ChannelOwner 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);
}
diff --git a/packages/playwright-core/src/server/common/selectorParser.ts b/packages/playwright-core/src/server/common/selectorParser.ts
index 9b37b455ee..9ded595647 100644
--- a/packages/playwright-core/src/server/common/selectorParser.ts
+++ b/packages/playwright-core/src/server/common/selectorParser.ts
@@ -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 {
+ const result = new Set();
+ 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;
diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts
index 1778a7f753..259e94cdeb 100644
--- a/packages/playwright-core/src/server/injected/injectedScript.ts
+++ b/packages/playwright-core/src/server/injected/injectedScript.ts
@@ -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(`
(() => {
diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts
index f9d7310f17..25d1a9a74b 100644
--- a/packages/playwright-core/src/server/selectors.ts
+++ b/packages/playwright-core/src/server/selectors.ts
@@ -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 {
diff --git a/packages/playwright-core/src/server/supplements/injected/consoleApi.ts b/packages/playwright-core/src/server/supplements/injected/consoleApi.ts
index e5a0613bdb..2f818eadd2 100644
--- a/packages/playwright-core/src/server/supplements/injected/consoleApi.ts
+++ b/packages/playwright-core/src/server/supplements/injected/consoleApi.ts
@@ -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;
diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts
index 0870e8a6cc..2fa0b5cf14 100644
--- a/packages/playwright-core/types/types.d.ts
+++ b/packages/playwright-core/types/types.d.ts
@@ -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 `Playwright
`.
+ *
+ * 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 `Playwright
`.
@@ -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 `Playwright
`.
+ *
+ * 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 `Playwright
`.
@@ -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 `Playwright
`.
+ *
+ * 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 `Playwright
`.
@@ -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 `Playwright
`.
+ *
+ * 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 `Playwright
`.
diff --git a/tests/inspector/console-api.spec.ts b/tests/inspector/console-api.spec.ts
index 73426ac7f8..2b35a6384c 100644
--- a/tests/inspector/console-api.spec.ts
+++ b/tests/inspector/console-api.spec.ts
@@ -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('Hi
Hello
');
+ 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');
+});
diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts
index a8fec6d97d..504adf1d2a 100644
--- a/tests/page/locator-query.spec.ts
+++ b/tests/page/locator-query.spec.ts
@@ -89,3 +89,47 @@ it('should filter by regex and regexp flags', async ({ page }) => {
await page.setContent(`Hello "world"
Hello world
`);
await expect(page.locator('div', { hasText: /hElLo "world"/i })).toHaveText('Hello "world"');
});
+
+it('should support has:locator', async ({ page }) => {
+ await page.setContent(`hello
world
`);
+ 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(`world
`);
+ 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(`hello
`);
+ 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(`world
`);
+ 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.');
+});
diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts
index 9314922d80..f82c6c5c32 100644
--- a/tests/page/selectors-misc.spec.ts
+++ b/tests/page/selectors-misc.spec.ts
@@ -339,3 +339,37 @@ it('should properly determine visibility of display:contents elements', async ({
`);
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(``);
+ 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(`hello
world
`);
+ 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(`world
`);
+ 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(`hello
`);
+ 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(`world
`);
+ expect(await page.$eval(`div >> has="span >> text=wor" >> span`, e => e.outerHTML)).toBe(`world`);
+
+ 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!"');
+});