diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index f2fa346294..f583780d56 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -145,7 +145,7 @@ export class Locator implements api.Locator { return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator, options); if (selectorOrLocator._frame !== this._frame) throw new Error(`Locators must belong to the same frame.`); - return new Locator(this._frame, this._selector + ' >> ' + selectorOrLocator._selector, options); + return new Locator(this._frame, this._selector + ' >> internal:chain=' + JSON.stringify(selectorOrLocator._selector), options); } getByTestId(testId: string | RegExp): Locator { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 0e425efbb5..dfa15dddeb 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -115,6 +115,7 @@ export class InjectedScript { this._engines.set('internal:has-not', this._createHasNotEngine()); this._engines.set('internal:and', { queryAll: () => [] }); this._engines.set('internal:or', { queryAll: () => [] }); + this._engines.set('internal:chain', this._createInternalChainEngine()); this._engines.set('internal:label', this._createInternalLabelEngine()); this._engines.set('internal:text', this._createTextEngine(true, true)); this._engines.set('internal:has-text', this._createInternalHasTextEngine()); @@ -399,6 +400,13 @@ export class InjectedScript { return { queryAll }; } + private _createInternalChainEngine(): SelectorEngine { + const queryAll = (root: SelectorRoot, body: NestedSelectorBody) => { + return this.querySelectorAll(body.parsed, root); + }; + return { queryAll }; + } + extend(source: string, params: any): any { const constrFunction = this.window.eval(` (() => { diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index f0dc4dbec3..b54a0269a5 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -38,7 +38,7 @@ export class Selectors { 'nth', 'visible', 'internal:control', 'internal:has', 'internal:has-not', 'internal:has-text', 'internal:has-not-text', - 'internal:and', 'internal:or', + 'internal:and', 'internal:or', 'internal:chain', '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 1f8c944e4c..8c1fd4f46d 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' | 'jsonl'; -export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'and' | 'or'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'and' | 'or' | 'chain'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; type LocatorOptions = { @@ -120,6 +120,11 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram tokens.push(inners.map(inner => factory.generateLocator(base, 'or', inner))); continue; } + if (part.name === 'internal:chain') { + const inners = innerAsLocators(factory, (part.body as NestedSelectorBody).parsed, false, maxOutputSize); + tokens.push(inners.map(inner => factory.generateLocator(base, 'chain', inner))); + continue; + } if (part.name === 'internal:label') { const { exact, text } = detectExact(part.body as string); tokens.push([factory.generateLocator(base, 'label', text, { exact })]); @@ -285,6 +290,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `and(${body})`; case 'or': return `or(${body})`; + case 'chain': + return `locator(${body})`; case 'test-id': return `getByTestId(${this.toTestIdValue(body)})`; case 'text': @@ -375,6 +382,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `and_(${body})`; case 'or': return `or_(${body})`; + case 'chain': + return `locator(${body})`; case 'test-id': return `get_by_test_id(${this.toTestIdValue(body)})`; case 'text': @@ -474,6 +483,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `and(${body})`; case 'or': return `or(${body})`; + case 'chain': + return `locator(${body})`; case 'test-id': return `getByTestId(${this.toTestIdValue(body)})`; case 'text': @@ -567,6 +578,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `And(${body})`; case 'or': return `Or(${body})`; + case 'chain': + return `Locator(${body})`; case 'test-id': return `GetByTestId(${this.toTestIdValue(body)})`; case 'text': diff --git a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts index 767ceb048d..f8733743c0 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorParser.ts @@ -105,7 +105,7 @@ function shiftParams(template: string, sub: number) { function transform(template: string, params: TemplateParams, testIdAttributeName: string): string { // Recursively handle filter(has=, hasnot=, sethas(), sethasnot()). - // TODO: handle and(locator), or(locator), locator(has=, hasnot=, sethas(), sethasnot()). + // TODO: handle and(locator), or(locator), locator(locator), locator(has=, hasnot=, sethas(), sethasnot()). while (true) { const hasMatch = template.match(/filter\(,?(has=|hasnot=|sethas\(|sethasnot\()/); if (!hasMatch) diff --git a/packages/playwright-core/src/utils/isomorphic/selectorParser.ts b/packages/playwright-core/src/utils/isomorphic/selectorParser.ts index 1f713a732d..5b96ecaac5 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', 'internal:has-not', 'internal:and', 'internal:or', 'left-of', 'right-of', 'above', 'below', 'near']); +const kNestedSelectorNames = new Set(['internal:has', 'internal:has-not', 'internal:and', 'internal:or', 'internal:chain', 'left-of', 'right-of', 'above', 'below', 'near']); const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']); export type ParsedSelectorPart = { diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 403f02765d..de551db28f 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -487,6 +487,13 @@ it('asLocator internal:or', async () => { expect.soft(asLocator('csharp', 'div >> internal:or="span >> article"', false)).toBe(`Locator("div").Or(Locator("span").Locator("article"))`); }); +it('asLocator internal:chain', async () => { + expect.soft(asLocator('javascript', 'div >> internal:chain="span >> article"', false)).toBe(`locator('div').locator(locator('span').locator('article'))`); + expect.soft(asLocator('python', 'div >> internal:chain="span >> article"', false)).toBe(`locator("div").locator(locator("span").locator("article"))`); + expect.soft(asLocator('java', 'div >> internal:chain="span >> article"', false)).toBe(`locator("div").locator(locator("span").locator("article"))`); + expect.soft(asLocator('csharp', 'div >> internal:chain="span >> article"', false)).toBe(`Locator("div").Locator(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 7631b0d20b..099e913a78 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -189,6 +189,21 @@ it('should support locator.or', async ({ page }) => { await expect(page.locator('span').or(page.locator('article'))).toHaveText('world'); }); +it('should support locator.locator with and/or', async ({ page }) => { + await page.setContent(` +