diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index b0e1f0ab83..f5da0a3995 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1495,6 +1495,51 @@ var banana = await page.GetByRole(AriaRole.Listitem).Nth(2); * since: v1.14 - `index` <[int]> + +## method: Locator.or +* since: v1.33 +* langs: + - alias-python: or_ +- returns: <[Locator]> + +Creates a locator that matches either of the two locators. + +**Usage** + +If your page shows a username input that is labelled either `Username` or `Login`, depending on some external factors you do not control, you can match both. + +```js +const input = page.getByLabel('Username').or(page.getByLabel('Login')); +await input.fill('John'); +``` + +```java +Locator input = page.getByLabel("Username").or(page.getByLabel("Login")); +input.fill("John"); +``` + +```python async +input = page.get_by_label("Username").or_(page.get_by_label("Login")) +await input.fill("John") +``` + +```python sync +input = page.get_by_label("Username").or_(page.get_by_label("Login")) +input.fill("John") +``` + +```csharp +var input = page.GetByLabel("Username").Or(page.GetByLabel("Login")); +await input.FillAsync("John"); +``` + +### param: Locator.or.locator +* since: v1.33 +- `locator` <[Locator]> + +Alternative locator to match. + + ## method: Locator.page * since: v1.19 - returns: <[Page]> diff --git a/docs/src/other-locators.md b/docs/src/other-locators.md index bb24522872..97bedfe25b 100644 --- a/docs/src/other-locators.md +++ b/docs/src/other-locators.md @@ -457,7 +457,40 @@ var parentLocator = page.GetByRole(AriaRole.Button).Locator(".."); ``` -### Locating only visible elements + +## Combining two alternative locators + +If you'd like to target one of the two or more elements, and you don't know which one it will be, use [`method: Locator.or`] to create a locator that matches any of the alternatives. + +For example, to fill the username input that is labelled either `Username` or `Login`, depending on some external factors: + +```js +const input = page.getByLabel('Username').or(page.getByLabel('Login')); +await input.fill('John'); +``` + +```java +Locator input = page.getByLabel("Username").or(page.getByLabel("Login")); +input.fill("John"); +``` + +```python async +input = page.get_by_label("Username").or_(page.get_by_label("Login")) +await input.fill("John") +``` + +```python sync +input = page.get_by_label("Username").or_(page.get_by_label("Login")) +input.fill("John") +``` + +```csharp +var input = page.GetByLabel("Username").Or(page.GetByLabel("Login")); +await input.FillAsync("John"); +``` + + +## Locating only visible elements :::note It's usually better to find a [more reliable way](./locators.md#quick-guide) to uniquely identify the element instead of checking the visibility. diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 1c6530d28c..ddfadc819a 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -192,6 +192,12 @@ export class Locator implements api.Locator { return new Locator(this._frame, this._selector + ` >> nth=${index}`); } + or(locator: Locator): Locator { + if (locator._frame !== this._frame) + throw new Error(`Locators must belong to the same frame.`); + return new Locator(this._frame, this._selector + ` >> internal:or=` + JSON.stringify(locator._selector)); + } + async focus(options?: TimeoutOptions): Promise { return this._frame.focus(this._selector, { strict: true, ...options }); } diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 50398c01e9..14d9f78ba7 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -57,6 +57,7 @@ class Locator { self.first = (): Locator => self.locator('nth=0'); self.last = (): Locator => self.locator('nth=-1'); self.nth = (index: number): Locator => self.locator(`nth=${index}`); + self.or = (locator: Locator): Locator => new Locator(injectedScript, selectorBase + ` >> internal:or=` + JSON.stringify((locator as any)[selectorSymbol])); } } @@ -88,6 +89,7 @@ class ConsoleAPI { delete this._injectedScript.window.playwright.first; delete this._injectedScript.window.playwright.last; delete this._injectedScript.window.playwright.nth; + delete this._injectedScript.window.playwright.or; } private _querySelector(selector: string, strict: boolean): (Element | undefined) { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 3a1017dd59..449f52ff14 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -23,7 +23,7 @@ import { parseAttributeSelector } from '../../utils/isomorphic/selectorParser'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../../utils/isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../../utils/isomorphic/selectorParser'; import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils'; -import { SelectorEvaluatorImpl } from './selectorEvaluator'; +import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator'; import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils'; import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector } from './selectorGenerator'; @@ -113,6 +113,7 @@ export class InjectedScript { this._engines.set('visible', this._createVisibleEngine()); this._engines.set('internal:control', this._createControlEngine()); this._engines.set('internal:has', this._createHasEngine()); + this._engines.set('internal:or', { queryAll: () => [] }); this._engines.set('internal:label', this._createInternalLabelEngine()); this._engines.set('internal:text', this._createTextEngine(true, true)); this._engines.set('internal:has-text', this._createInternalHasTextEngine()); @@ -169,6 +170,14 @@ export class InjectedScript { return new Set(list.slice(nth, nth + 1)); } + private _queryOr(elements: Set, part: ParsedSelectorPart): Set { + const list = [...elements]; + let nth = +part.body; + if (nth === -1) + nth = list.length - 1; + return new Set(list.slice(nth, nth + 1)); + } + private _queryLayoutSelector(elements: Set, part: ParsedSelectorPart, originalRoot: Node): Set { const name = part.name as LayoutSelectorName; const body = part.body as NestedSelectorBody; @@ -210,6 +219,9 @@ export class InjectedScript { for (const part of selector.parts) { if (part.name === 'nth') { roots = this._queryNth(roots, part); + } else if (part.name === 'internal:or') { + const orElements = this.querySelectorAll((part.body as NestedSelectorBody).parsed, root); + roots = new Set(sortInDOMOrder(new Set([...roots, ...orElements]))); } else if (kLayoutSelectorNames.includes(part.name as LayoutSelectorName)) { roots = this._queryLayoutSelector(roots, part, root); } else { diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index bbb23a85a1..5428e22500 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -518,7 +518,7 @@ function previousSiblingInContext(element: Element, context: QueryContext): Elem return element.previousElementSibling || undefined; } -function sortInDOMOrder(elements: Element[]): Element[] { +export function sortInDOMOrder(elements: Iterable): Element[] { type SortEntry = { children: Element[], taken: boolean }; const elementToEntry = new Map(); @@ -540,7 +540,8 @@ function sortInDOMOrder(elements: Element[]): Element[] { elementToEntry.set(element, entry); return entry; } - elements.forEach(e => append(e).taken = true); + for (const e of elements) + append(e).taken = true; function visit(element: Element) { const entry = elementToEntry.get(element)!; diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index 399f3b7198..49fecbb9f3 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -35,7 +35,7 @@ export class Selectors { 'data-testid', 'data-testid:light', 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', - 'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text', + 'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-text', 'internal:or', 'role', 'internal:attr', 'internal:label', 'internal:text', 'internal:role', 'internal:testid', ]); this._builtinEnginesInMainWorld = new Set([ diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts index 05e8047981..fa3642a3e9 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts @@ -19,7 +19,7 @@ import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringi import type { ParsedSelector } from './selectorParser'; export type Language = 'javascript' | 'python' | 'java' | 'csharp'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has' | 'frame'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has' | 'frame' | 'or'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; @@ -86,6 +86,11 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame tokens.push(factory.generateLocator(base, 'has', inner)); continue; } + if (part.name === 'internal:or') { + const inner = innerAsLocator(factory, (part.body as NestedSelectorBody).parsed); + tokens.push(factory.generateLocator(base, 'or', inner)); + continue; + } if (part.name === 'internal:label') { const { exact, text } = detectExact(part.body as string); tokens.push(factory.generateLocator(base, 'label', text, { exact })); @@ -195,6 +200,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `filter({ hasText: ${this.toHasText(body as string)} })`; case 'has': return `filter({ has: ${body} })`; + case 'or': + return `or(${body})`; case 'test-id': return `getByTestId(${this.quote(body as string)})`; case 'text': @@ -263,6 +270,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `filter(has_text=${this.toHasText(body as string)})`; case 'has': return `filter(has=${body})`; + case 'or': + return `or_(${body})`; case 'test-id': return `get_by_test_id(${this.quote(body as string)})`; case 'text': @@ -340,6 +349,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`; case 'has': return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; + case 'or': + return `or(${body})`; case 'test-id': return `getByTestId(${this.quote(body as string)})`; case 'text': @@ -411,6 +422,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `Filter(new() { ${this.toHasText(body)} })`; case 'has': return `Filter(new() { Has = ${body} })`; + case 'or': + return `Or(${body})`; case 'test-id': return `GetByTestId(${this.quote(body as string)})`; case 'text': diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index 76e0f31937..486d36241c 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -77,6 +77,7 @@ function parseLocator(locator: string, testIdAttributeName: string): string { .replace(/new\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '') .replace(/\.set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) + .replace(/\._or\(/g, 'or(') // Python has "_or" instead of "or". .replace(/:/g, '=') .replace(/,re\.ignorecase/g, 'i') .replace(/,pattern.case_insensitive/g, 'i') @@ -101,6 +102,7 @@ function shiftParams(template: string, sub: number) { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { // Recursively handle filter(has=). + // TODO: handle or(locator) as well. while (true) { const hasMatch = template.match(/filter\(,?has=/); if (!hasMatch) diff --git a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts index 3606204624..41ff749a30 100644 --- a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts @@ -19,7 +19,7 @@ import { InvalidSelectorError, parseCSS } from './cssParser'; export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; -const kNestedSelectorNames = new Set(['internal:has', 'left-of', 'right-of', 'above', 'below', 'near']); +const kNestedSelectorNames = new Set(['internal:has', 'internal:or', 'left-of', 'right-of', 'above', 'below', 'near']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']); export type ParsedSelectorPart = { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 85b02db334..9bc771be15 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -11446,6 +11446,23 @@ export interface Locator { */ nth(index: number): Locator; + /** + * Creates a locator that matches either of the two locators. + * + * **Usage** + * + * If your page shows a username input that is labelled either `Username` or `Login`, depending on some external + * factors you do not control, you can match both. + * + * ```js + * const input = page.getByLabel('Username').or(page.getByLabel('Login')); + * await input.fill('John'); + * ``` + * + * @param locator Alternative locator to match. + */ + or(locator: Locator): Locator; + /** * A page this locator belongs to. */ diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index e7afa12e1b..6e1dc7b430 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -67,6 +67,11 @@ it('should support playwright.locator({ has })', async ({ page }) => { expect(await page.evaluate(`playwright.locator('div', { has: playwright.locator('text=Hello') }).element.innerHTML`)).toContain('span'); }); +it('should support playwright.or()', async ({ page }) => { + await page.setContent('
Hi
Hello'); + expect(await page.evaluate(`playwright.locator('div').or(playwright.locator('span')).elements.map(e => e.innerHTML)`)).toEqual(['Hi', 'Hello']); +}); + it('should support playwright.getBy*', async ({ page }) => { await page.setContent('HelloWorld'); expect(await page.evaluate(`playwright.getByText('hello').element.innerHTML`)).toContain('Hello'); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 3d9f844c53..72ea285933 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -352,6 +352,13 @@ it.describe(() => { }); }); +it('asLocator internal:or', async () => { + expect.soft(asLocator('javascript', 'div >> internal:or="span >> article"', false)).toBe(`locator('div').or(locator('span').locator('article'))`); + expect.soft(asLocator('python', 'div >> internal:or="span >> article"', false)).toBe(`locator("div").or_(locator("span").locator("article"))`); + expect.soft(asLocator('java', 'div >> internal:or="span >> article"', false)).toBe(`locator("div").or(locator("span").locator("article"))`); + expect.soft(asLocator('csharp', 'div >> internal:or="span >> article"', false)).toBe(`Locator("div").Or(Locator("span").Locator("article"))`); +}); + it('parse locators strictly', () => { const selector = 'div >> internal:has-text=\"Goodbye world\"i >> span'; diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index eca8731d43..f811b8118e 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -159,6 +159,18 @@ it('should support locator.filter', async ({ page, trace }) => { })).toHaveCount(1); }); +it('should support locator.or', async ({ page }) => { + await page.setContent(`
hello
world`); + await expect(page.locator('div').or(page.locator('span'))).toHaveCount(2); + await expect(page.locator('div').or(page.locator('span'))).toHaveText(['hello', 'world']); + await expect(page.locator('span').or(page.locator('article')).or(page.locator('div'))).toHaveText(['hello', 'world']); + await expect(page.locator('article').or(page.locator('someting'))).toHaveCount(0); + await expect(page.locator('article').or(page.locator('div'))).toHaveText('hello'); + await expect(page.locator('article').or(page.locator('span'))).toHaveText('world'); + await expect(page.locator('div').or(page.locator('article'))).toHaveText('hello'); + await expect(page.locator('span').or(page.locator('article'))).toHaveText('world'); +}); + it('should enforce same frame for has/leftOf/rightOf/above/below/near', async ({ page, server }) => { await page.goto(server.PREFIX + '/frames/two-frames.html'); const child = page.frames()[1]; diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index 241f6d8c26..6b40c1d059 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -400,6 +400,20 @@ it('should work with internal:has=', async ({ page, server }) => { expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"'); }); +it('should work with internal:or=', async ({ page, server }) => { + await page.setContent(` +
hello
+ world + `); + expect(await page.$$eval(`div >> internal:or="span"`, els => els.map(e => e.textContent))).toEqual(['hello', 'world']); + expect(await page.$$eval(`span >> internal:or="div"`, els => els.map(e => e.textContent))).toEqual(['hello', 'world']); + expect(await page.$$eval(`article >> internal:or="something"`, els => els.length)).toBe(0); + expect(await page.locator(`article >> internal:or="div"`).textContent()).toBe('hello'); + expect(await page.locator(`article >> internal:or="span"`).textContent()).toBe('world'); + expect(await page.locator(`div >> internal:or="article"`).textContent()).toBe('hello'); + expect(await page.locator(`span >> internal:or="article"`).textContent()).toBe('world'); +}); + it('chaining should work with large DOM @smoke', async ({ page, server }) => { await page.evaluate(() => { let last = document.body;