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!"'); +});