From 54dd6d01e5cf4847b40ded0663e8c3e6a60cd939 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 3 May 2022 10:33:33 +0100 Subject: [PATCH] feat(locator): layout options (leftOf, rightOf, above, below, near) (#13821) This also includes corresponding selector engines `left-of` and others, modeled after existing `has` selector engine. --- docs/src/api/params.md | 50 ++ packages/playwright-core/src/client/frame.ts | 4 +- .../playwright-core/src/client/locator.ts | 36 +- packages/playwright-core/src/client/page.ts | 4 +- .../src/server/injected/injectedScript.ts | 46 +- .../server/injected/layoutSelectorUtils.ts | 76 +++ .../src/server/injected/selectorEvaluator.ts | 72 +-- .../src/server/isomorphic/selectorParser.ts | 25 +- .../playwright-core/src/server/selectors.ts | 1 + packages/playwright-core/types/types.d.ts | 470 ++++++++++++++++++ tests/config/experimental.d.ts | 470 ++++++++++++++++++ tests/page/locator-query.spec.ts | 18 +- tests/page/selectors-misc.spec.ts | 74 ++- 13 files changed, 1259 insertions(+), 87 deletions(-) create mode 100644 packages/playwright-core/src/server/injected/layoutSelectorUtils.ts diff --git a/docs/src/api/params.md b/docs/src/api/params.md index cdc5cb9f0c..d29cf842f7 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -897,9 +897,59 @@ For example, `article` that has `text=Playwright` matches `
Playwri Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. +## locator-option-left-of +- `leftOf` <[Locator]|[Object]> + - `locator` <[Locator]> The inner locator. + - `maxDistance` ?<[float]> Maximum horizontal distance between the elements in pixels, unlimited by default. + +Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator is queried against the same root as the outer one. More details in [layout selectors](../selectors.md#selecting-elements-based-on-layout) guide. + +Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + +## locator-option-right-of +- `rightOf` <[Locator]|[Object]> + - `locator` <[Locator]> The inner locator. + - `maxDistance` ?<[float]> Maximum horizontal distance between the elements in pixels, unlimited by default. + +Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner locator is queried against the same root as the outer one. More details in [layout selectors](../selectors.md#selecting-elements-based-on-layout) guide. + +Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + +## locator-option-above +- `above` <[Locator]|[Object]> + - `locator` <[Locator]> The inner locator. + - `maxDistance` ?<[float]> Maximum vertical distance between the elements in pixels, unlimited by default. + +Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner locator is queried against the same root as the outer one. More details in [layout selectors](../selectors.md#selecting-elements-based-on-layout) guide. + +Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + +## locator-option-below +- `below` <[Locator]|[Object]> + - `locator` <[Locator]> The inner locator. + - `maxDistance` ?<[float]> Maximum vertical distance between the elements in pixels, unlimited by default. + +Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner locator is queried against the same root as the outer one. More details in [layout selectors](../selectors.md#selecting-elements-based-on-layout) guide. + +Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + +## locator-option-near +- `near` <[Locator]|[Object]> + - `locator` <[Locator]> The inner locator. + - `maxDistance` ?<[float]> Maximum distance between the elements in pixels, 50 by default. + +Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same root as the outer one. More details in [layout selectors](../selectors.md#selecting-elements-based-on-layout) guide. + +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-%% +- %%-locator-option-left-of-%% +- %%-locator-option-right-of-%% +- %%-locator-option-above-%% +- %%-locator-option-below-%% +- %%-locator-option-near-%% ## screenshot-option-animations - `animations` <[ScreenshotAnimations]<"disabled"|"allow">> diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index 285025267e..2745325fbb 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -18,7 +18,7 @@ import { assert } from '../utils'; import type * as channels from '../protocol/channels'; import { ChannelOwner } from './channelOwner'; -import { FrameLocator, Locator } from './locator'; +import { FrameLocator, Locator, type LocatorOptions } from './locator'; import { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle'; import { assertMaxArguments, JSHandle, serializeArgument, parseResult } from './jsHandle'; import fs from 'fs'; @@ -290,7 +290,7 @@ export class Frame extends ChannelOwner implements api.Fr return await this._channel.highlight({ selector }); } - locator(selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator { + locator(selector: string, options?: LocatorOptions): 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 7f5bb57c11..936d39cace 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -26,11 +26,21 @@ import type { FilePayload, FrameExpectOptions, Rect, SelectOption, SelectOptionO import { parseResult, serializeArgument } from './jsHandle'; import { escapeWithQuotes } from '../utils/isomorphic/stringUtils'; +export type LocatorOptions = { + hasText?: string | RegExp; + has?: Locator; + leftOf?: Locator | { locator: Locator, maxDistance?: number }; + rightOf?: Locator | { locator: Locator, maxDistance?: number }; + above?: Locator | { locator: Locator, maxDistance?: number }; + below?: Locator | { locator: Locator, maxDistance?: number }; + near?: Locator | { locator: Locator, maxDistance?: number }; +}; + export class Locator implements api.Locator { _frame: Frame; _selector: string; - constructor(frame: Frame, selector: string, options?: { hasText?: string | RegExp, has?: Locator }) { + constructor(frame: Frame, selector: string, options?: LocatorOptions) { this._frame = frame; this._selector = selector; @@ -47,6 +57,26 @@ export class Locator implements api.Locator { throw new Error(`Inner "has" locator must belong to the same frame.`); this._selector += ` >> has=` + JSON.stringify(options.has._selector); } + + for (const inner of ['leftOf', 'rightOf', 'above', 'below', 'near'] as const) { + const value = options?.[inner]; + if (!value) + continue; + let maxDistance: number | undefined; + let locator: Locator; + if (value instanceof Locator) { + locator = value; + } else { + locator = value.locator; + maxDistance = value.maxDistance; + } + if (locator._frame !== frame) + throw new Error(`Inner "${inner}" locator must belong to the same frame.`); + if (maxDistance !== undefined && typeof maxDistance !== 'number') + throw new Error(`"${inner}.maxDistance" must be a number, found ${typeof maxDistance}.`); + const engineName = inner === 'leftOf' ? 'left-of' : (inner === 'rightOf' ? 'right-of' : inner); + this._selector += ` >> ${engineName}=` + JSON.stringify(locator._selector) + (maxDistance === undefined ? '' : ',' + maxDistance); + } } private async _withElement(task: (handle: ElementHandle, timeout?: number) => Promise, timeout?: number): Promise { @@ -122,7 +152,7 @@ export class Locator implements api.Locator { return this._frame._highlight(this._selector); } - locator(selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator { + locator(selector: string, options?: LocatorOptions): Locator { return new Locator(this._frame, this._selector + ' >> ' + selector, options); } @@ -130,7 +160,7 @@ export class Locator implements api.Locator { return new FrameLocator(this._frame, this._selector + ' >> ' + selector); } - that(options?: { hasText?: string | RegExp, has?: Locator }): Locator { + that(options?: LocatorOptions): Locator { return new Locator(this._frame, this._selector, options); } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index ad054decc9..c0e1b94339 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -28,7 +28,7 @@ import { ConsoleMessage } from './consoleMessage'; import { Dialog } from './dialog'; import { Download } from './download'; import { ElementHandle, determineScreenshotType } from './elementHandle'; -import type { Locator, FrameLocator } from './locator'; +import type { Locator, FrameLocator, LocatorOptions } from './locator'; import { Worker } from './worker'; import type { WaitForNavigationOptions } from './frame'; import { Frame, verifyLoadState } from './frame'; @@ -578,7 +578,7 @@ export class Page extends ChannelOwner implements api.Page return this._mainFrame.fill(selector, value, options); } - locator(selector: string, options?: { hasText?: string | RegExp, has?: Locator }): Locator { + locator(selector: string, options?: LocatorOptions): Locator { return this.mainFrame().locator(selector, options); } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 61d3833d89..94e7c52c05 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -19,7 +19,7 @@ import { XPathEngine } from './xpathSelectorEngine'; import { ReactEngine } from './reactSelectorEngine'; import { VueEngine } from './vueSelectorEngine'; import { RoleEngine } from './roleSelectorEngine'; -import type { ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; +import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; import type { TextMatcher } from './selectorEvaluator'; import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator'; @@ -28,6 +28,7 @@ import { generateSelector } from './selectorGenerator'; import type * as channels from '../../protocol/channels'; import { Highlight } from './highlight'; import { getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils'; +import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; type Predicate = (progress: InjectedScriptProgress) => T | symbol; @@ -102,6 +103,11 @@ export class InjectedScript { this._engines.set('visible', this._createVisibleEngine()); this._engines.set('control', this._createControlEngine()); this._engines.set('has', this._createHasEngine()); + this._engines.set('left-of', { queryAll: () => [] }); + this._engines.set('right-of', { queryAll: () => [] }); + this._engines.set('above', { queryAll: () => [] }); + this._engines.set('below', { queryAll: () => [] }); + this._engines.set('near', { queryAll: () => [] }); for (const { name, engine } of customEngines) this._engines.set(name, engine); @@ -140,22 +146,36 @@ export class InjectedScript { return result[0]; } - private _queryNth(roots: Set, part: ParsedSelectorPart): Set { - const list = [...roots]; + private _queryNth(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; + const result: { element: Element, score: number }[] = []; + const inner = this.querySelectorAll(body.parsed, originalRoot); + for (const element of elements) { + const score = layoutSelectorScore(name, element, inner, body.distance); + if (score !== undefined) + result.push({ element, score }); + } + result.sort((a, b) => a.score - b.score); + return new Set(result.map(r => r.element)); + } + querySelectorAll(selector: ParsedSelector, root: Node): Element[] { if (selector.capture !== undefined) { if (selector.parts.some(part => part.name === 'nth')) throw this.createStacklessError(`Can't query n-th element in a request with the capture.`); const withHas: ParsedSelector = { parts: selector.parts.slice(0, selector.capture + 1) }; if (selector.capture < selector.parts.length - 1) { - const body = { parts: selector.parts.slice(selector.capture + 1) }; - const has: ParsedSelectorPart = { name: 'has', body, source: stringifySelector(body) }; + const parsed: ParsedSelector = { parts: selector.parts.slice(selector.capture + 1) }; + const has: ParsedSelectorPart = { name: 'has', body: { parsed }, source: stringifySelector(parsed) }; withHas.parts.push(has); } return this.querySelectorAll(withHas, root); @@ -175,6 +195,8 @@ export class InjectedScript { for (const part of selector.parts) { if (part.name === 'nth') { roots = this._queryNth(roots, part); + } else if (kLayoutSelectorNames.includes(part.name as LayoutSelectorName)) { + roots = this._queryLayoutSelector(roots, part, root); } else { const next = new Set(); for (const root of roots) { @@ -266,10 +288,10 @@ export class InjectedScript { } private _createHasEngine(): SelectorEngineV2 { - const queryAll = (root: SelectorRoot, body: ParsedSelector) => { + const queryAll = (root: SelectorRoot, body: NestedSelectorBody) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; - const has = !!this.querySelector(body, root, false); + const has = !!this.querySelector(body.parsed, root, false); return has ? [root as Element] : []; }; return { queryAll }; @@ -284,6 +306,16 @@ export class InjectedScript { return { queryAll }; } + private _createLayoutEngine(name: LayoutSelectorName): 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 = globalThis.eval(` (() => { diff --git a/packages/playwright-core/src/server/injected/layoutSelectorUtils.ts b/packages/playwright-core/src/server/injected/layoutSelectorUtils.ts new file mode 100644 index 0000000000..c9665e2b1a --- /dev/null +++ b/packages/playwright-core/src/server/injected/layoutSelectorUtils.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function boxRightOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { + const distance = box1.left - box2.right; + if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) + return; + return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} + +function boxLeftOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { + const distance = box2.left - box1.right; + if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) + return; + return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} + +function boxAbove(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { + const distance = box2.top - box1.bottom; + if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) + return; + return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} + +function boxBelow(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { + const distance = box1.top - box2.bottom; + if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) + return; + return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} + +function boxNear(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { + const kThreshold = maxDistance === undefined ? 50 : maxDistance; + let score = 0; + if (box1.left - box2.right >= 0) + score += box1.left - box2.right; + if (box2.left - box1.right >= 0) + score += box2.left - box1.right; + if (box2.top - box1.bottom >= 0) + score += box2.top - box1.bottom; + if (box1.top - box2.bottom >= 0) + score += box1.top - box2.bottom; + return score > kThreshold ? undefined : score; +} + +export type LayoutSelectorName = 'left-of' | 'right-of' | 'above' | 'below' | 'near'; +export const kLayoutSelectorNames: LayoutSelectorName[] = ['left-of', 'right-of', 'above', 'below', 'near']; + +export function layoutSelectorScore(name: LayoutSelectorName, element: Element, inner: Element[], maxDistance: number | undefined): number | undefined { + const box = element.getBoundingClientRect(); + const scorer = { 'left-of': boxLeftOf, 'right-of': boxRightOf, 'above': boxAbove, 'below': boxBelow, 'near': boxNear }[name]; + let bestScore: number | undefined; + for (const e of inner) { + if (e === element) + continue; + const score = scorer(box, e.getBoundingClientRect(), maxDistance); + if (score === undefined) + continue; + if (bestScore === undefined || score < bestScore) + bestScore = score; + } + return bestScore; +} diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index 2dac43a688..087758a089 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -16,6 +16,7 @@ import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../isomorphic/cssParser'; import { customCSSNames } from '../isomorphic/selectorParser'; +import { type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; export type QueryContext = { scope: Element | Document; @@ -61,11 +62,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { this._engines.set('text-is', textIsEngine); this._engines.set('text-matches', textMatchesEngine); this._engines.set('has-text', hasTextEngine); - this._engines.set('right-of', createPositionEngine('right-of', boxRightOf)); - this._engines.set('left-of', createPositionEngine('left-of', boxLeftOf)); - this._engines.set('above', createPositionEngine('above', boxAbove)); - this._engines.set('below', createPositionEngine('below', boxBelow)); - this._engines.set('near', createPositionEngine('near', boxNear)); + this._engines.set('right-of', createLayoutEngine('right-of')); + this._engines.set('left-of', createLayoutEngine('left-of')); + this._engines.set('above', createLayoutEngine('above')); + this._engines.set('below', createLayoutEngine('below')); + this._engines.set('near', createLayoutEngine('near')); this._engines.set('nth-match', nthMatchEngine); const allNames = [...this._engines.keys()]; @@ -541,69 +542,18 @@ export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: El return 'self'; } -function boxRightOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { - const distance = box1.left - box2.right; - if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) - return; - return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); -} - -function boxLeftOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { - const distance = box2.left - box1.right; - if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) - return; - return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); -} - -function boxAbove(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { - const distance = box2.top - box1.bottom; - if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) - return; - return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); -} - -function boxBelow(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { - const distance = box1.top - box2.bottom; - if (distance < 0 || (maxDistance !== undefined && distance > maxDistance)) - return; - return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); -} - -function boxNear(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined { - const kThreshold = maxDistance === undefined ? 50 : maxDistance; - let score = 0; - if (box1.left - box2.right >= 0) - score += box1.left - box2.right; - if (box2.left - box1.right >= 0) - score += box2.left - box1.right; - if (box2.top - box1.bottom >= 0) - score += box2.top - box1.bottom; - if (box1.top - box2.bottom >= 0) - score += box1.top - box2.bottom; - return score > kThreshold ? undefined : score; -} - -function createPositionEngine(name: string, scorer: (box1: DOMRect, box2: DOMRect, maxDistance: number | undefined) => number | undefined): SelectorEngine { +function createLayoutEngine(name: LayoutSelectorName): SelectorEngine { return { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { const maxDistance = args.length && typeof args[args.length - 1] === 'number' ? args[args.length - 1] : undefined; const queryArgs = maxDistance === undefined ? args : args.slice(0, args.length - 1); if (args.length < 1 + (maxDistance === undefined ? 0 : 1)) throw new Error(`"${name}" engine expects a selector list and optional maximum distance in pixels`); - const box = element.getBoundingClientRect(); - let bestScore: number | undefined; - for (const e of evaluator.query(context, queryArgs)) { - if (e === element) - continue; - const score = scorer(box, e.getBoundingClientRect(), maxDistance); - if (score === undefined) - continue; - if (bestScore === undefined || score < bestScore) - bestScore = score; - } - if (bestScore === undefined) + const inner = evaluator.query(context, queryArgs); + const score = layoutSelectorScore(name, element, inner, maxDistance); + if (score === undefined) return false; - (evaluator as SelectorEvaluatorImpl)._markScore(element, bestScore); + (evaluator as SelectorEvaluatorImpl)._markScore(element, score); return true; } }; diff --git a/packages/playwright-core/src/server/isomorphic/selectorParser.ts b/packages/playwright-core/src/server/isomorphic/selectorParser.ts index db58f151c3..0a62a2f3ab 100644 --- a/packages/playwright-core/src/server/isomorphic/selectorParser.ts +++ b/packages/playwright-core/src/server/isomorphic/selectorParser.ts @@ -18,9 +18,13 @@ import type { CSSComplexSelectorList } from './cssParser'; import { InvalidSelectorError, parseCSS } from './cssParser'; export { InvalidSelectorError, isInvalidSelectorError } from './cssParser'; +export type NestedSelectorBody = { parsed: ParsedSelector, distance?: number }; +const kNestedSelectorNames = new Set(['has', 'left-of', 'right-of', 'above', 'below', 'near']); +const kNestedSelectorNamesWithDistance = new Set(['left-of', 'right-of', 'above', 'below', 'near']); + export type ParsedSelectorPart = { name: string, - body: string | CSSComplexSelectorList | ParsedSelector, + body: string | CSSComplexSelectorList | NestedSelectorBody, source: string, }; @@ -35,7 +39,6 @@ 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); @@ -52,16 +55,22 @@ export function parseSelector(selector: string): ParsedSelector { } if (kNestedSelectorNames.has(part.name)) { let innerSelector: string; + let distance: number | undefined; try { - const unescaped = JSON.parse(part.body); - if (typeof unescaped !== 'string') + const unescaped = JSON.parse('[' + part.body + ']'); + if (!Array.isArray(unescaped) || unescaped.length < 1 || unescaped.length > 2 || typeof unescaped[0] !== 'string') throw new Error(`Malformed selector: ${part.name}=` + part.body); - innerSelector = unescaped; + innerSelector = unescaped[0]; + if (unescaped.length === 2) { + if (typeof unescaped[1] !== 'number' || !kNestedSelectorNamesWithDistance.has(part.name)) + throw new Error(`Malformed selector: ${part.name}=` + part.body); + distance = unescaped[1]; + } } 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')) + const result = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } }; + if (result.body.parsed.parts.some(part => part.name === 'control' && part.body === 'enter-frame')) throw new Error(`Frames are not allowed inside "${part.name}" selectors`); return result; } @@ -119,7 +128,7 @@ export function allEngineNames(selector: ParsedSelector): Set { for (const part of selector.parts) { result.add(part.name); if (kNestedSelectorNames.has(part.name)) - visit(part.body as ParsedSelector); + visit((part.body as NestedSelectorBody).parsed); } }; visit(selector); diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index 5ac6ab379a..78bb4e5434 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -46,6 +46,7 @@ export class Selectors { 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', 'nth', 'visible', 'control', 'has', + 'left-of', 'right-of', 'above', 'below', 'near', ]); this._builtinEnginesInMainWorld = new Set([ '_react', '_vue', diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index bbb9da1d60..003b8107b5 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2619,6 +2619,44 @@ export interface Page { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -2633,6 +2671,62 @@ export interface Page { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -5443,6 +5537,44 @@ export interface Frame { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -5457,6 +5589,62 @@ export interface Frame { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -9417,6 +9605,44 @@ export interface Locator { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -9431,6 +9657,62 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -9812,6 +10094,44 @@ export interface Locator { * @param options */ that(options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -9826,6 +10146,62 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -13923,6 +14299,44 @@ export interface FrameLocator { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -13937,6 +14351,62 @@ export interface FrameLocator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** diff --git a/tests/config/experimental.d.ts b/tests/config/experimental.d.ts index 9eb57bd5b5..bc1920b4c3 100644 --- a/tests/config/experimental.d.ts +++ b/tests/config/experimental.d.ts @@ -2621,6 +2621,44 @@ export interface Page { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -2635,6 +2673,62 @@ export interface Page { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -5445,6 +5539,44 @@ export interface Frame { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -5459,6 +5591,62 @@ export interface Frame { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -9426,6 +9614,44 @@ export interface Locator { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -9440,6 +9666,62 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -9821,6 +10103,44 @@ export interface Locator { * @param options */ that(options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -9835,6 +10155,62 @@ export interface Locator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** @@ -13932,6 +14308,44 @@ export interface FrameLocator { * @param options */ locator(selector: string, options?: { + /** + * Matches elements that are above any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + above?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are below any of the elements matching the inner locator, at any horizontal position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + below?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum vertical distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + /** * 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
`. @@ -13946,6 +14360,62 @@ export interface FrameLocator { * `
Playwright
`. */ hasText?: string|RegExp; + + /** + * Matches elements that are to the left of any element matching the inner locator, at any vertical position. Inner locator + * is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + leftOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are near any of the elements matching the inner locator. Inner locator is queried against the same + * root as the outer one. More details in [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + near?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum distance between the elements in pixels, 50 by default. + */ + maxDistance?: number; + }; + + /** + * Matches elements that are to the right of any element matching the inner locator, at any vertical position. Inner + * locator is queried against the same root as the outer one. More details in + * [layout selectors](https://playwright.dev/docs/selectors#selecting-elements-based-on-layout) guide. + * + * Note that outer and inner locators must belong to the same frame. Inner locator must not contain [FrameLocator]s. + */ + rightOf?: Locator|{ + /** + * The inner locator. + */ + locator: Locator; + + /** + * Maximum horizontal distance between the elements in pixels, unlimited by default. + */ + maxDistance?: number; + }; }): Locator; /** diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index cc6fd9cb1e..3c3015ba36 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -141,14 +141,26 @@ it('should support locator.that', async ({ page, trace }) => { })).toHaveCount(1); }); -it('should enforce same frame for has:locator', async ({ page, server }) => { +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]; + for (const option of ['has', 'leftOf', 'rightOf', 'above', 'below', 'near']) { + let error; + try { + page.locator('div', { [option]: child.locator('span') }); + } catch (e) { + error = e; + } + expect(error.message).toContain(`Inner "${option}" locator must belong to the same frame.`); + } +}); + +it('should check leftOf options', async ({ page }) => { let error; try { - page.locator('div', { has: child.locator('span') }); + page.locator('div', { leftOf: { locator: page.locator('span'), maxDistance: 'abc' } as any }); } catch (e) { error = e; } - expect(error.message).toContain('Inner "has" locator must belong to the same frame.'); + expect(error.message).toContain(`"leftOf.maxDistance" must be a number, found string.`); }); diff --git a/tests/page/selectors-misc.spec.ts b/tests/page/selectors-misc.spec.ts index fbd13c5190..0a10b43614 100644 --- a/tests/page/selectors-misc.spec.ts +++ b/tests/page/selectors-misc.spec.ts @@ -162,7 +162,9 @@ it('should work with strict mode and chaining', async ({ page }) => { expect(await page.locator('div >> div >> span').textContent()).toBe('hi'); }); -it('should work with position selectors', async ({ page }) => { +it('should work with layout selectors', async ({ page, trace }) => { + it.skip(trace === 'on'); + /* +--+ +--+ @@ -214,60 +216,130 @@ it('should work with position selectors', async ({ page }) => { div.style.width = box[2] + 'px'; div.style.height = box[3] + 'px'; container.appendChild(div); + const span = document.createElement('span'); + span.textContent = '' + i; + div.appendChild(span); } }, boxes); expect(await page.$eval('div:right-of(#id6)', e => e.id)).toBe('id7'); + expect(await page.$eval('div >> right-of="#id6"', e => e.id)).toBe('id7'); + expect(await page.locator('div', { rightOf: page.locator('#id6') }).first().evaluate(e => e.id)).toBe('id7'); expect(await page.$eval('div:right-of(#id1)', e => e.id)).toBe('id2'); + expect(await page.$eval('div >> right-of="#id1"', e => e.id)).toBe('id2'); expect(await page.$eval('div:right-of(#id3)', e => e.id)).toBe('id4'); + expect(await page.$eval('div >> right-of="#id3"', e => e.id)).toBe('id4'); expect(await page.$('div:right-of(#id4)')).toBe(null); + expect(await page.$('div >> right-of="#id4"')).toBe(null); expect(await page.$eval('div:right-of(#id0)', e => e.id)).toBe('id7'); + expect(await page.$eval('div >> right-of="#id0"', e => e.id)).toBe('id7'); expect(await page.$eval('div:right-of(#id8)', e => e.id)).toBe('id9'); + expect(await page.$eval('div >> right-of="#id8"', e => e.id)).toBe('id9'); expect(await page.$$eval('div:right-of(#id3)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id5,id7,id8,id9'); + expect(await page.$$eval('div >> right-of="#id3"', els => els.map(e => e.id).join(','))).toBe('id4,id2,id5,id7,id8,id9'); + expect(await page.locator('div', { rightOf: page.locator('#id3') }).locator('span').evaluateAll(els => els.map(e => e.textContent).join(','))).toBe('4,2,5,7,8,9'); expect(await page.$$eval('div:right-of(#id3, 50)', els => els.map(e => e.id).join(','))).toBe('id2,id5,id7,id8'); + expect(await page.$$eval('div >> right-of="#id3",50', els => els.map(e => e.id).join(','))).toBe('id2,id5,id7,id8'); + expect(await page.$$eval('div >> right-of="#id3",50 >> span', els => els.map(e => e.textContent).join(','))).toBe('2,5,7,8'); + expect(await page.locator('div', { + rightOf: { locator: page.locator('#id3'), maxDistance: 50 }, + }).locator('span').evaluateAll(els => els.map(e => e.textContent).join(','))).toBe('2,5,7,8'); expect(await page.$$eval('div:right-of(#id3, 49)', els => els.map(e => e.id).join(','))).toBe('id7,id8'); + expect(await page.$$eval('div >> right-of="#id3",49', els => els.map(e => e.id).join(','))).toBe('id7,id8'); + expect(await page.$$eval('div >> right-of="#id3",49 >> span', els => els.map(e => e.textContent).join(','))).toBe('7,8'); + expect(await page.locator('div', { + rightOf: { locator: page.locator('#id3'), maxDistance: 49 }, + }).locator('span').evaluateAll(els => els.map(e => e.textContent).join(','))).toBe('7,8'); expect(await page.$eval('div:left-of(#id2)', e => e.id)).toBe('id1'); + expect(await page.$eval('div >> left-of="#id2"', e => e.id)).toBe('id1'); + expect(await page.locator('div', { leftOf: page.locator('#id2') }).first().evaluate(e => e.id)).toBe('id1'); expect(await page.$('div:left-of(#id0)')).toBe(null); + expect(await page.$('div >> left-of="#id0"')).toBe(null); expect(await page.$eval('div:left-of(#id5)', e => e.id)).toBe('id0'); + expect(await page.$eval('div >> left-of="#id5"', e => e.id)).toBe('id0'); expect(await page.$eval('div:left-of(#id9)', e => e.id)).toBe('id8'); + expect(await page.$eval('div >> left-of="#id9"', e => e.id)).toBe('id8'); expect(await page.$eval('div:left-of(#id4)', e => e.id)).toBe('id3'); + expect(await page.$eval('div >> left-of="#id4"', e => e.id)).toBe('id3'); expect(await page.$$eval('div:left-of(#id5)', els => els.map(e => e.id).join(','))).toBe('id0,id7,id3,id1,id6,id8'); + expect(await page.$$eval('div >> left-of="#id5"', els => els.map(e => e.id).join(','))).toBe('id0,id7,id3,id1,id6,id8'); expect(await page.$$eval('div:left-of(#id5, 3)', els => els.map(e => e.id).join(','))).toBe('id7,id8'); + expect(await page.$$eval('div >> left-of="#id5",3', els => els.map(e => e.id).join(','))).toBe('id7,id8'); + expect(await page.$$eval('div >> left-of="#id5",3 >> span', els => els.map(e => e.textContent).join(','))).toBe('7,8'); expect(await page.$eval('div:above(#id0)', e => e.id)).toBe('id3'); + expect(await page.$eval('div >> above="#id0"', e => e.id)).toBe('id3'); + expect(await page.locator('div', { above: page.locator('#id0') }).first().evaluate(e => e.id)).toBe('id3'); expect(await page.$eval('div:above(#id5)', e => e.id)).toBe('id4'); + expect(await page.$eval('div >> above="#id5"', e => e.id)).toBe('id4'); expect(await page.$eval('div:above(#id7)', e => e.id)).toBe('id5'); + expect(await page.$eval('div >> above="#id7"', e => e.id)).toBe('id5'); expect(await page.$eval('div:above(#id8)', e => e.id)).toBe('id0'); + expect(await page.$eval('div >> above="#id8"', e => e.id)).toBe('id0'); expect(await page.$eval('div:above(#id9)', e => e.id)).toBe('id8'); + expect(await page.$eval('div >> above="#id9"', e => e.id)).toBe('id8'); expect(await page.$('div:above(#id2)')).toBe(null); + expect(await page.$('div >> above="#id2"')).toBe(null); expect(await page.$$eval('div:above(#id5)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id3,id1'); + expect(await page.$$eval('div >> above="#id5"', els => els.map(e => e.id).join(','))).toBe('id4,id2,id3,id1'); expect(await page.$$eval('div:above(#id5, 20)', els => els.map(e => e.id).join(','))).toBe('id4,id3'); + expect(await page.$$eval('div >> above="#id5",20', els => els.map(e => e.id).join(','))).toBe('id4,id3'); expect(await page.$eval('div:below(#id4)', e => e.id)).toBe('id5'); + expect(await page.$eval('div >> below="#id4"', e => e.id)).toBe('id5'); + expect(await page.locator('div', { below: page.locator('#id4') }).first().evaluate(e => e.id)).toBe('id5'); expect(await page.$eval('div:below(#id3)', e => e.id)).toBe('id0'); + expect(await page.$eval('div >> below="#id3"', e => e.id)).toBe('id0'); expect(await page.$eval('div:below(#id2)', e => e.id)).toBe('id4'); + expect(await page.$eval('div >> below="#id2"', e => e.id)).toBe('id4'); expect(await page.$eval('div:below(#id6)', e => e.id)).toBe('id8'); + expect(await page.$eval('div >> below="#id6"', e => e.id)).toBe('id8'); expect(await page.$eval('div:below(#id7)', e => e.id)).toBe('id8'); + expect(await page.$eval('div >> below="#id7"', e => e.id)).toBe('id8'); expect(await page.$eval('div:below(#id8)', e => e.id)).toBe('id9'); + expect(await page.$eval('div >> below="#id8"', e => e.id)).toBe('id9'); expect(await page.$('div:below(#id9)')).toBe(null); + expect(await page.$('div >> below="#id9"')).toBe(null); expect(await page.$$eval('div:below(#id3)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7,id8,id9'); + expect(await page.$$eval('div >> below="#id3"', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7,id8,id9'); expect(await page.$$eval('div:below(#id3, 105)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7'); + expect(await page.$$eval('div >> below="#id3" , 105', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7'); expect(await page.$eval('div:near(#id0)', e => e.id)).toBe('id3'); + expect(await page.$eval('div >> near="#id0"', e => e.id)).toBe('id3'); + expect(await page.locator('div', { near: page.locator('#id0') }).first().evaluate(e => e.id)).toBe('id3'); expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id3,id6'); + expect(await page.$$eval('div >> near="#id7"', els => els.map(e => e.id).join(','))).toBe('id0,id5,id3,id6'); expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5'); + expect(await page.$$eval('div >> near="#id0"', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5'); expect(await page.$$eval('div:near(#id6)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id7'); + expect(await page.$$eval('div >> near="#id6"', els => els.map(e => e.id).join(','))).toBe('id0,id3,id7'); expect(await page.$$eval('div:near(#id6, 10)', els => els.map(e => e.id).join(','))).toBe('id0'); + expect(await page.$$eval('div >> near="#id6",10', els => els.map(e => e.id).join(','))).toBe('id0'); expect(await page.$$eval('div:near(#id0, 100)', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5,id4,id2'); + expect(await page.$$eval('div >> near="#id0",100', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5,id4,id2'); expect(await page.$$eval('div:below(#id5):above(#id8)', els => els.map(e => e.id).join(','))).toBe('id7,id6'); + expect(await page.$$eval('div >> below="#id5" >> above="#id8"', els => els.map(e => e.id).join(','))).toBe('id7,id6'); expect(await page.$eval('div:below(#id5):above(#id8)', e => e.id)).toBe('id7'); + expect(await page.$eval('div >> below="#id5" >> above="#id8"', e => e.id)).toBe('id7'); + expect(await page.locator('div', { below: page.locator('#id5'), above: page.locator('#id8') }).first().evaluate(e => e.id)).toBe('id7'); expect(await page.$$eval('div:right-of(#id0) + div:above(#id8)', els => els.map(e => e.id).join(','))).toBe('id5,id6,id3'); const error = await page.$(':near(50)').catch(e => e); expect(error.message).toContain('"near" engine expects a selector list and optional maximum distance in pixels'); + const error1 = await page.$(`div >> left-of=abc`).catch(e => e); + expect(error1.message).toContain('Malformed selector: left-of=abc'); + const error2 = await page.$(`left-of="div"`).catch(e => e); + expect(error2.message).toContain('"left-of" selector cannot be first'); + const error3 = await page.$(`div >> left-of=33`).catch(e => e); + expect(error3.message).toContain('Malformed selector: left-of=33'); + const error4 = await page.$(`div >> left-of="span","foo"`).catch(e => e); + expect(error4.message).toContain('Malformed selector: left-of="span","foo"'); + const error5 = await page.$(`div >> left-of="span",3,4`).catch(e => e); + expect(error5.message).toContain('Malformed selector: left-of="span",3,4'); }); it('should escape the scope with >>', async ({ page }) => {