From 632b1c576ea5dca1f59b5aa65a4f86d5c9827d56 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 25 Aug 2026 12:21:09 +0100 Subject: [PATCH 1/4] feat(selectors): replace pierceFrames() with anyFrame() page.anyFrame() and frame.anyFrame() start the match in any frame of the subtree, and the rest of the selector resolves inside that single frame, just like a regular locator. This drops the mid-chain frame piercing that pierceFrames() did, together with the restrictions it required: composite locators, *-capture and mid-chain nth are all allowed now. Also removes the pierceFrames context and test option. Codegen prefers an anyFrame() prefix whenever it saves at least two frameLocator() calls and still pinpoints the target frame. --- docs/src/api/class-frame.md | 85 +++--- docs/src/api/class-page.md | 85 +++--- docs/src/api/params.md | 7 - docs/src/test-api/class-testoptions.md | 15 -- packages/injected/src/injectedScript.ts | 2 +- packages/isomorphic/locatorGenerators.ts | 30 +-- packages/isomorphic/locatorParser.ts | 4 +- packages/isomorphic/selectorParser.ts | 31 +-- packages/playwright-client/types/types.d.ts | 122 +++------ .../playwright-core/src/client/channels.d.ts | 11 - packages/playwright-core/src/client/frame.ts | 7 +- .../playwright-core/src/client/locator.ts | 17 +- packages/playwright-core/src/client/page.ts | 4 +- .../src/server/browserContext.ts | 1 - .../playwright-core/src/server/channels.d.ts | 11 - .../src/server/frameSelectors.ts | 207 +++++---------- packages/playwright-core/src/server/frames.ts | 8 +- .../src/server/highlightController.ts | 15 +- packages/playwright-core/src/server/page.ts | 6 +- .../playwright-core/src/server/recorder.ts | 16 +- .../src/server/recorder/recorderUtils.ts | 15 +- packages/playwright-core/types/types.d.ts | 122 +++------ packages/playwright/src/index.ts | 4 - packages/playwright/types/test.d.ts | 20 -- packages/protocol/spec/electron.yml | 1 - packages/protocol/spec/mixins.yml | 1 - packages/protocol/src/validator.ts | 6 - .../browsercontext-pierce-frames.spec.ts | 88 ------- tests/library/inspector/cli-codegen-3.spec.ts | 121 +++------ tests/library/locator-generator.spec.ts | 22 +- ...ames.spec.ts => locator-any-frame.spec.ts} | 249 +++++++++--------- tests/page/selectors-frame.spec.ts | 48 ++-- tests/playwright-test/playwright.spec.ts | 18 -- utils/generate_types/overrides-test.d.ts | 1 - 34 files changed, 488 insertions(+), 912 deletions(-) delete mode 100644 tests/library/browsercontext-pierce-frames.spec.ts rename tests/page/{locator-pierce-frames.spec.ts => locator-any-frame.spec.ts} (63%) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 56db47d57f52c..a956e52099973 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -185,6 +185,44 @@ current working directory. Raw CSS content to be injected into frame. +## method: Frame.anyFrame +* since: v1.63 +- returns: <[FrameLocator]> + +When working with iframes, you can create a frame locator that will start the search in this frame +or in any of the iframes inside it, so that you don't need to locate each iframe first. + +Note that the rest of the locator is resolved inside a single frame, just like any other locator. If it matches elements inside multiple frames, an error is thrown. + +**Usage** + +Following snippet locates a button, either in the frame or in one of the iframes inside it: + +```js +const locator = frame.anyFrame().getByRole('button'); +await locator.click(); +``` + +```java +Locator locator = frame.anyFrame().getByRole(AriaRole.BUTTON); +locator.click(); +``` + +```python async +locator = frame.any_frame.get_by_role("button") +await locator.click() +``` + +```python sync +locator = frame.any_frame.get_by_role("button") +locator.click() +``` + +```csharp +var locator = frame.AnyFrame.GetByRole(AriaRole.Button); +await locator.ClickAsync(); +``` + ## async method: Frame.check * since: v1.8 * discouraged: Use locator-based [`method: Locator.check`] instead. Read more about [locators](../locators.md). @@ -1439,53 +1477,6 @@ Returns the page containing this frame. Parent frame, if any. Detached frames and main frames return `null`. -## method: Frame.pierceFrames -* since: v1.63 -- returns: <[FrameLocator]> - -When working with iframes, you can create a frame locator that will search for elements in this frame -and in all iframes inside it, so that you don't need to locate each iframe first. - -Note that all elements matching the locator must belong to a single frame. For example, if the frame contains -two iframes, each with a `Submit` button, piercing frames and locating a button will throw an error -because it matches elements from multiple frames. - -**Usage** - -Following snippet locates a button, either in the frame or in one of the iframes inside it: - -```js -const locator = frame.pierceFrames().getByRole('button'); -await locator.click(); -``` - -```java -Locator locator = frame.pierceFrames().getByRole(AriaRole.BUTTON); -locator.click(); -``` - -```python async -locator = frame.pierce_frames.get_by_role("button") -await locator.click() -``` - -```python sync -locator = frame.pierce_frames.get_by_role("button") -locator.click() -``` - -```csharp -var locator = frame.PierceFrames.GetByRole(AriaRole.Button); -await locator.ClickAsync(); -``` - -### option: Frame.pierceFrames.pierce -* since: v1.63 -- `pierce` <[boolean]> - -Whether to pierce frames. Pass `false` to opt out of frame piercing enabled -by the [`option: Browser.newContext.pierceFrames`] context option. Defaults to `true`. - ## async method: Frame.press * since: v1.8 * discouraged: Use locator-based [`method: Locator.press`] instead. Read more about [locators](../locators.md). diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 9cb83bef41cdf..8de652788bccd 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -719,6 +719,44 @@ current working directory. Raw CSS content to be injected into frame. +## method: Page.anyFrame +* since: v1.63 +- returns: <[FrameLocator]> + +When working with iframes, you can create a frame locator that will start the search in any frame +on the page - the main frame or any of the iframes - so that you don't need to locate each iframe first. + +Note that the rest of the locator is resolved inside a single frame, just like any other locator. If it matches elements inside multiple frames, an error is thrown. + +**Usage** + +Following snippet locates a button, either in the main frame or in one of the iframes: + +```js +const locator = page.anyFrame().getByRole('button'); +await locator.click(); +``` + +```java +Locator locator = page.anyFrame().getByRole(AriaRole.BUTTON); +locator.click(); +``` + +```python async +locator = page.any_frame.get_by_role("button") +await locator.click() +``` + +```python sync +locator = page.any_frame.get_by_role("button") +locator.click() +``` + +```csharp +var locator = page.AnyFrame.GetByRole(AriaRole.Button); +await locator.ClickAsync(); +``` + ## async method: Page.bringToFront * since: v1.8 @@ -3168,53 +3206,6 @@ var locator = await page.PickLocatorAsync(); Console.WriteLine(locator); ``` -## method: Page.pierceFrames -* since: v1.63 -- returns: <[FrameLocator]> - -When working with iframes, you can create a frame locator that will search for elements in the main frame -and in all iframes on the page, so that you don't need to locate each iframe first. - -Note that all elements matching the locator must belong to a single frame. For example, if the page contains -two iframes, each with a `Submit` button, piercing frames and locating a button will throw an error -because it matches elements from multiple frames. - -**Usage** - -Following snippet locates a button, either in the main frame or in one of the iframes: - -```js -const locator = page.pierceFrames().getByRole('button'); -await locator.click(); -``` - -```java -Locator locator = page.pierceFrames().getByRole(AriaRole.BUTTON); -locator.click(); -``` - -```python async -locator = page.pierce_frames.get_by_role("button") -await locator.click() -``` - -```python sync -locator = page.pierce_frames.get_by_role("button") -locator.click() -``` - -```csharp -var locator = page.PierceFrames.GetByRole(AriaRole.Button); -await locator.ClickAsync(); -``` - -### option: Page.pierceFrames.pierce -* since: v1.63 -- `pierce` <[boolean]> - -Whether to pierce frames. Pass `false` to opt out of frame piercing enabled -by the [`option: Browser.newContext.pierceFrames`] context option. Defaults to `true`. - ## async method: Page.press * since: v1.8 * discouraged: Use locator-based [`method: Locator.press`] instead. Read more about [locators](../locators.md). diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9a812dc0e586a..bf7a70b9074db 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -885,12 +885,6 @@ Actual picture of each page will be scaled down if necessary to fit the specifie Network proxy settings to use with this context. Defaults to none. -## context-option-pierce-frames -- `pierceFrames` <[boolean]> - -If set to true, all selectors in this context will pierce frames by default, as if every locator -was created through [`method: Page.pierceFrames`]. Defaults to `false`. - ## context-option-strict - `strictSelectors` <[boolean]> @@ -1130,7 +1124,6 @@ between the same pixel in compared images, between zero (strict) and one (lax), - %%-context-option-recordvideo-%% - %%-context-option-recordvideo-dir-%% - %%-context-option-recordvideo-size-%% -- %%-context-option-pierce-frames-%% - %%-context-option-strict-%% - %%-context-option-service-worker-policy-%% diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 5e8dfa649381c..5777731f99a99 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -492,21 +492,6 @@ export default defineConfig({ }); ``` -## property: TestOptions.pierceFrames = %%-context-option-pierce-frames-%% -* since: v1.63 - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/test'; - -export default defineConfig({ - use: { - pierceFrames: true, - } -}); -``` - ## property: TestOptions.proxy = %%-browser-option-proxy-%% * since: v1.10 diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 674c605f8851f..891d5e152752a 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -543,7 +543,7 @@ export class InjectedScript { queryAll(root: SelectorRoot, body: any) { if (body === 'enter-frame') return []; - if (body === 'pierce-frames') + if (body === 'any-frame') return []; if (body === 'return-empty') return []; diff --git a/packages/isomorphic/locatorGenerators.ts b/packages/isomorphic/locatorGenerators.ts index f0e037ac769eb..554c9f681a736 100644 --- a/packages/isomorphic/locatorGenerators.ts +++ b/packages/isomorphic/locatorGenerators.ts @@ -21,7 +21,7 @@ import type { NestedSelectorBody } from './selectorParser'; 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' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'pierce-frames' | 'no-pierce-frames' | 'and' | 'or' | 'chain'; +export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'visible' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'frame-locator' | 'any-frame' | 'and' | 'or' | 'chain'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; export type Quote = '\'' | '"' | '`'; @@ -205,8 +205,8 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram continue; } } - if (part.name === 'internal:control' && ((part.body as string) === 'pierce-frames' || (part.body as string) === 'no-pierce-frames')) { - tokens.push([factory.generateLocator(base, part.body as LocatorType, '')]); + if (part.name === 'internal:control' && (part.body as string) === 'any-frame') { + tokens.push([factory.generateLocator(base, 'any-frame', '')]); nextBase = 'frame-locator'; continue; } @@ -320,10 +320,8 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `frameLocator(${this.quote(body as string)})`; case 'frame': return `contentFrame()`; - case 'pierce-frames': - return `pierceFrames()`; - case 'no-pierce-frames': - return `pierceFrames({ pierce: false })`; + case 'any-frame': + return `anyFrame()`; case 'nth': return `nth(${body})`; case 'first': @@ -423,10 +421,8 @@ export class PythonLocatorFactory implements LocatorFactory { return `frame_locator(${this.quote(body as string)})`; case 'frame': return `content_frame`; - case 'pierce-frames': - return `pierce_frames`; - case 'no-pierce-frames': - return `pierce_frames(pierce=False)`; + case 'any-frame': + return `any_frame`; case 'nth': return `nth(${body})`; case 'first': @@ -539,10 +535,8 @@ export class JavaLocatorFactory implements LocatorFactory { return `frameLocator(${this.quote(body as string)})`; case 'frame': return `contentFrame()`; - case 'pierce-frames': - return `pierceFrames()`; - case 'no-pierce-frames': - return `pierceFrames(new ${clazz}.PierceFramesOptions().setPierce(false))`; + case 'any-frame': + return `anyFrame()`; case 'nth': return `nth(${body})`; case 'first': @@ -645,10 +639,8 @@ export class CSharpLocatorFactory implements LocatorFactory { return `FrameLocator(${this.quote(body as string)})`; case 'frame': return `ContentFrame`; - case 'pierce-frames': - return `PierceFrames`; - case 'no-pierce-frames': - return `PierceFrames(new() { Pierce = false })`; + case 'any-frame': + return `AnyFrame`; case 'nth': return `Nth(${body})`; case 'first': diff --git a/packages/isomorphic/locatorParser.ts b/packages/isomorphic/locatorParser.ts index fcb417b5563d1..ab69b25de75b2 100644 --- a/packages/isomorphic/locatorParser.ts +++ b/packages/isomorphic/locatorParser.ts @@ -78,7 +78,7 @@ function parseLocator(locator: string, testIdAttributeName: string): { selector: .replace(/has_not/g, 'hasnot') .replace(/frame_locator/g, 'framelocator') .replace(/content_frame/g, 'contentframe') - .replace(/pierce_frames/g, 'pierceframes') + .replace(/any_frame/g, 'anyframe') .replace(/[{}\s]/g, '') .replace(/new\(\)/g, '') .replace(/new[\w]+\.[\w]+options\(\)/g, '') @@ -160,7 +160,7 @@ function transform(template: string, params: TemplateParams, testIdAttributeName .replace(/\,set([\w]+)\(([^)]+)\)/g, (_, group1, group2) => ',' + group1.toLowerCase() + '=' + group2.toLowerCase()) .replace(/framelocator\(([^)]+)\)/g, '$1.internal:control=enter-frame') .replace(/contentframe(\(\))?/g, 'internal:control=enter-frame') - .replace(/pierceframes(\(\))?/g, 'internal:control=pierce-frames') + .replace(/anyframe(\(\))?/g, 'internal:control=any-frame') .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') .replace(/locator\(([^)]+),hasnottext=([^),]+)\)/g, 'locator($1).internal:has-not-text=$2') .replace(/locator\(([^)]+),hastext=([^),]+)\)/g, 'locator($1).internal:has-text=$2') diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index a13afb8030a03..97cc20e214480 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -91,25 +91,26 @@ export function parseSelector(selector: string): ParsedSelector { }; } -// Splits a selector into per-frame chunks separated by "enter-frame" boundaries in non-piercing mode. -// In piercing mode, "enter-frame" tokens are preserved, so `chunks` holds a single chunk. -export function splitSelectorByFrame(selectorText: string, pierceByDefault?: boolean): { pierce: boolean, chunks: ParsedSelector[] } { +// Matches in any frame of the subtree, instead of the frame itself. Only allowed as the first token. +export const kAnyFrameSelector = 'internal:control=any-frame'; + +// Splits a selector into per-frame chunks separated by "enter-frame" boundaries. +// The optional leading "any-frame" token is consumed and reported separately. +export function splitSelectorByFrame(selectorText: string): { anyFrame: boolean, chunks: ParsedSelector[] } { const selector = parseSelector(selectorText); const chunks: ParsedSelector[] = []; let chunk: ParsedSelector = { parts: [], }; - let pierce = !!pierceByDefault; - let pierceToken = false; + let anyFrame = false; let chunkStartIndex = 0; for (let i = 0; i < selector.parts.length; ++i) { const part = selector.parts[i]; - if (part.name === 'internal:control' && (part.body === 'pierce-frames' || part.body === 'no-pierce-frames')) { - // Piercing applies to the whole selector, so the token only makes sense as the very first one. + if (part.name === 'internal:control' && part.body === 'any-frame') { + // The starting frame applies to the whole selector, so the token only makes sense as the very first one. if (i !== 0) throw new InvalidSelectorError(`"${part.body}" is only allowed as the first selector token, while parsing selector ${selectorText}`); - pierce = part.body === 'pierce-frames'; - pierceToken = true; + anyFrame = true; chunkStartIndex = i + 1; continue; } @@ -117,10 +118,6 @@ export function splitSelectorByFrame(selectorText: string, pierceByDefault?: boo const lastPart = chunk.parts[chunk.parts.length - 1]; if (!lastPart || (lastPart.name === 'internal:control' && lastPart.body === 'enter-frame')) throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first'); - if (pierce) { - chunk.parts.push(part); - continue; - } chunks.push(chunk); chunk = { parts: [] }; chunkStartIndex = i + 1; @@ -131,8 +128,8 @@ export function splitSelectorByFrame(selectorText: string, pierceByDefault?: boo chunk.parts.push(part); } if (!chunk.parts.length) { - if (pierceToken) - throw new InvalidSelectorError(`Selector cannot be empty when piercing frames, while parsing selector ${selectorText}`); + if (anyFrame && !chunks.length) + throw new InvalidSelectorError(`Selector cannot be empty after anyFrame(), while parsing selector ${selectorText}`); throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`); } const lastPart = chunk.parts[chunk.parts.length - 1]; @@ -141,9 +138,7 @@ export function splitSelectorByFrame(selectorText: string, pierceByDefault?: boo chunks.push(chunk); if (typeof selector.capture === 'number' && typeof chunks[chunks.length - 1].capture !== 'number') throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`); - if (typeof selector.capture === 'number' && pierce) - throw new InvalidSelectorError(`Can not *-capture inside a frame-piercing selector, while parsing selector ${selectorText}`); - return { pierce, chunks }; + return { anyFrame, chunks }; } function selectorPartsEqual(list1: ParsedSelectorPart[], list2: ParsedSelectorPart[]) { diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 6de9373f54049..088f6596273a8 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -2094,6 +2094,25 @@ export interface Page { url?: string; }): Promise; + /** + * When working with iframes, you can create a frame locator that will start the search in any frame on the page - the + * main frame or any of the iframes - so that you don't need to locate each iframe first. + * + * Note that the rest of the locator is resolved inside a single frame, just like any other locator. If it matches + * elements inside multiple frames, an error is thrown. + * + * **Usage** + * + * Following snippet locates a button, either in the main frame or in one of the iframes: + * + * ```js + * const locator = page.anyFrame().getByRole('button'); + * await locator.click(); + * ``` + * + */ + anyFrame(): FrameLocator; + /** * Captures the aria snapshot of the page. Read more about [aria snapshots](https://playwright.dev/docs/aria-snapshots). * @param options @@ -4219,34 +4238,6 @@ export interface Page { */ pickLocator(): Promise; - /** - * When working with iframes, you can create a frame locator that will search for elements in the main frame and in - * all iframes on the page, so that you don't need to locate each iframe first. - * - * Note that all elements matching the locator must belong to a single frame. For example, if the page contains two - * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches - * elements from multiple frames. - * - * **Usage** - * - * Following snippet locates a button, either in the main frame or in one of the iframes: - * - * ```js - * const locator = page.pierceFrames().getByRole('button'); - * await locator.click(); - * ``` - * - * @param options - */ - pierceFrames(options?: { - /** - * Whether to pierce frames. Pass `false` to opt out of frame piercing enabled by the - * [`pierceFrames`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-pierce-frames) context - * option. Defaults to `true`. - */ - pierce?: boolean; - }): FrameLocator; - /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). @@ -6745,6 +6736,25 @@ export interface Frame { url?: string; }): Promise; + /** + * When working with iframes, you can create a frame locator that will start the search in this frame or in any of the + * iframes inside it, so that you don't need to locate each iframe first. + * + * Note that the rest of the locator is resolved inside a single frame, just like any other locator. If it matches + * elements inside multiple frames, an error is thrown. + * + * **Usage** + * + * Following snippet locates a button, either in the frame or in one of the iframes inside it: + * + * ```js + * const locator = frame.anyFrame().getByRole('button'); + * await locator.click(); + * ``` + * + */ + anyFrame(): FrameLocator; + /** * **NOTE** Use locator-based [locator.check([options])](https://playwright.dev/docs/api/class-locator#locator-check) instead. * Read more about [locators](https://playwright.dev/docs/locators). @@ -8268,34 +8278,6 @@ export interface Frame { */ parentFrame(): null|Frame; - /** - * When working with iframes, you can create a frame locator that will search for elements in this frame and in all - * iframes inside it, so that you don't need to locate each iframe first. - * - * Note that all elements matching the locator must belong to a single frame. For example, if the frame contains two - * iframes, each with a `Submit` button, piercing frames and locating a button will throw an error because it matches - * elements from multiple frames. - * - * **Usage** - * - * Following snippet locates a button, either in the frame or in one of the iframes inside it: - * - * ```js - * const locator = frame.pierceFrames().getByRole('button'); - * await locator.click(); - * ``` - * - * @param options - */ - pierceFrames(options?: { - /** - * Whether to pierce frames. Pass `false` to opt out of frame piercing enabled by the - * [`pierceFrames`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-pierce-frames) context - * option. Defaults to `true`. - */ - pierce?: boolean; - }): FrameLocator; - /** * **NOTE** Use locator-based [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) * instead. Read more about [locators](https://playwright.dev/docs/locators). @@ -11492,13 +11474,6 @@ export interface Browser { */ permissions?: Array; - /** - * If set to true, all selectors in this context will pierce frames by default, as if every locator was created - * through [page.pierceFrames([options])](https://playwright.dev/docs/api/class-page#page-pierce-frames). Defaults to - * `false`. - */ - pierceFrames?: boolean; - /** * Network proxy settings to use with this context. Defaults to none. */ @@ -17816,13 +17791,6 @@ export interface BrowserType { */ permissions?: Array; - /** - * If set to true, all selectors in this context will pierce frames by default, as if every locator was created - * through [page.pierceFrames([options])](https://playwright.dev/docs/api/class-page#page-pierce-frames). Defaults to - * `false`. - */ - pierceFrames?: boolean; - /** * Network proxy settings. */ @@ -24664,13 +24632,6 @@ export interface AndroidDevice { */ permissions?: Array; - /** - * If set to true, all selectors in this context will pierce frames by default, as if every locator was created - * through [page.pierceFrames([options])](https://playwright.dev/docs/api/class-page#page-pierce-frames). Defaults to - * `false`. - */ - pierceFrames?: boolean; - /** * Optional package name to launch instead of default Chrome for Android. */ @@ -25888,13 +25849,6 @@ export interface BrowserContextOptions { */ permissions?: Array; - /** - * If set to true, all selectors in this context will pierce frames by default, as if every locator was created - * through [page.pierceFrames([options])](https://playwright.dev/docs/api/class-page#page-pierce-frames). Defaults to - * `false`. - */ - pierceFrames?: boolean; - /** * Network proxy settings to use with this context. Defaults to none. */ diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 52b5210534c0d..e89843c521937 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -428,7 +428,6 @@ export type AndroidDeviceLaunchBrowserParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -496,7 +495,6 @@ export type AndroidDeviceLaunchBrowserOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -877,7 +875,6 @@ export type BrowserNewContextParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -948,7 +945,6 @@ export type BrowserNewContextOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1022,7 +1018,6 @@ export type BrowserNewContextForReuseParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1093,7 +1088,6 @@ export type BrowserNewContextForReuseOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1206,7 +1200,6 @@ export type BrowserContextInitializer = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1850,7 +1843,6 @@ export type BrowserTypeLaunchPersistentContextParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1934,7 +1926,6 @@ export type BrowserTypeLaunchPersistentContextOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -2035,7 +2026,6 @@ export type ElectronLaunchParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, timezoneId?: string, tracesDir?: string, artifactsDir?: string, @@ -2075,7 +2065,6 @@ export type ElectronLaunchOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, timezoneId?: string, tracesDir?: string, artifactsDir?: string, diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index a6065b5535284..72a82dc127df3 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import { assertionAbortedMessage } from '@isomorphic/abortSignal'; import { assert } from '@isomorphic/assert'; import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '@isomorphic/locatorUtils'; +import { kAnyFrameSelector } from '@isomorphic/selectorParser'; import { urlMatches } from '@isomorphic/urlMatch'; import { EventEmitter } from './eventEmitter'; import { ChannelOwner } from './channelOwner'; @@ -28,7 +29,7 @@ import { ElementHandle, convertInputFiles, convertSelectOptionValues } from './e import { AbortError, PlaywrightError } from './errors'; import { Events } from './events'; import { JSHandle, assertEvaluateOptions, assertMaxArguments, parseResult, serializeArgument, serializeArgumentWithCallbacks } from './jsHandle'; -import { FrameLocator, kNoPierceFramesSelector, kPierceFramesSelector, Locator, testIdAttributeName } from './locator'; +import { FrameLocator, Locator, testIdAttributeName } from './locator'; import * as network from './network'; import { kLifecycleEvents } from './types'; import { Waiter } from './waiter'; @@ -397,8 +398,8 @@ export class Frame extends ChannelOwner implements api.Fr return new FrameLocator(this, selector); } - pierceFrames(options?: { pierce?: boolean }): FrameLocator { - return new FrameLocator(this, options?.pierce === false ? kNoPierceFramesSelector : kPierceFramesSelector); + anyFrame(): FrameLocator { + return new FrameLocator(this, kAnyFrameSelector); } async focus(selector: string, options: channels.FrameFocusOptions & TimeoutOptions = {}) { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 04628d4bae1e9..4be2ef36df3a4 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -18,6 +18,7 @@ import { inspect } from 'util'; import { asLocatorDescription, locatorCustomDescription } from '@isomorphic/locatorGenerators'; import { getByAltTextSelector, getByLabelSelector, getByPlaceholderSelector, getByRoleSelector, getByTestIdSelector, getByTextSelector, getByTitleSelector } from '@isomorphic/locatorUtils'; +import { kAnyFrameSelector } from '@isomorphic/selectorParser'; import { escapeForTextSelector } from '@isomorphic/stringUtils'; import { isString } from '@isomorphic/rtti'; import { monotonicTime } from '@isomorphic/time'; @@ -427,14 +428,6 @@ export class Locator implements api.Locator { } } -export const kPierceFramesSelector = 'internal:control=pierce-frames'; - -export const kNoPierceFramesSelector = 'internal:control=no-pierce-frames'; - -function selectorPiercesFrames(selector: string): boolean { - return selector === kPierceFramesSelector || selector.startsWith(kPierceFramesSelector + ' >> '); -} - export class FrameLocator implements api.FrameLocator { private _frame: Frame; private _frameSelector: string; @@ -445,7 +438,7 @@ export class FrameLocator implements api.FrameLocator { } private _childSelector(selector: string): string { - if (this._frameSelector === kPierceFramesSelector || this._frameSelector === kNoPierceFramesSelector) + if (this._frameSelector === kAnyFrameSelector) return this._frameSelector + ' >> ' + selector; return this._frameSelector + ' >> internal:control=enter-frame >> ' + selector; } @@ -495,10 +488,8 @@ export class FrameLocator implements api.FrameLocator { } private _nthSelector(nth: string): string { - if (selectorPiercesFrames(this._frameSelector)) - throw new Error(`Selecting the nth frame is not allowed while piercing frames.`); - if (this._frameSelector === kNoPierceFramesSelector) - throw new Error(`Selecting the nth frame is not allowed on the root frame locator.`); + if (this._frameSelector === kAnyFrameSelector) + throw new Error(`Selecting the nth frame is not allowed on anyFrame().`); return this._frameSelector + ` >> nth=${nth}`; } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index e29eb28a4c46c..230b6db93ea43 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -787,8 +787,8 @@ export class Page extends ChannelOwner implements api.Page return this.mainFrame().frameLocator(selector); } - pierceFrames(options?: { pierce?: boolean }): FrameLocator { - return this.mainFrame().pierceFrames(options); + anyFrame(): FrameLocator { + return this.mainFrame().anyFrame(); } async focus(selector: string, options?: channels.FrameFocusOptions & TimeoutOptions) { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index b2c1db68747d5..7b13ced77ec34 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -856,7 +856,6 @@ const defaultNewContextParamValues: channels.BrowserNewContextForReuseParams = { hasTouch: false, acceptDownloads: 'accept', strictSelectors: false, - pierceFrames: false, serviceWorkers: 'allow', locale: 'en-US', }; diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 87fec7205bc8e..7339e66c8ad63 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -429,7 +429,6 @@ export type AndroidDeviceLaunchBrowserParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -497,7 +496,6 @@ export type AndroidDeviceLaunchBrowserOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -878,7 +876,6 @@ export type BrowserNewContextParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -949,7 +946,6 @@ export type BrowserNewContextOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1023,7 +1019,6 @@ export type BrowserNewContextForReuseParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1094,7 +1089,6 @@ export type BrowserNewContextForReuseOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1207,7 +1201,6 @@ export type BrowserContextInitializer = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1851,7 +1844,6 @@ export type BrowserTypeLaunchPersistentContextParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -1935,7 +1927,6 @@ export type BrowserTypeLaunchPersistentContextOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, serviceWorkers?: 'allow' | 'block', selectorEngines?: SelectorEngine[], testIdAttributeName?: string, @@ -2036,7 +2027,6 @@ export type ElectronLaunchParams = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, timezoneId?: string, tracesDir?: string, artifactsDir?: string, @@ -2076,7 +2066,6 @@ export type ElectronLaunchOptions = { }, }, strictSelectors?: boolean, - pierceFrames?: boolean, timezoneId?: string, tracesDir?: string, artifactsDir?: string, diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 3b254611c77d8..cf1b3d18e6882 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -120,49 +120,91 @@ export class FrameSelectors { return jumptToFrame; } - async resolveFramesForSelector(selector: string, options: types.StrictOptions & { pierce?: 'default' | 'pierce' | 'no-pierce' } = {}, scope?: ElementHandle): Promise { - const pierceByDefault = options.pierce === 'pierce' || (options.pierce !== 'no-pierce' && !!this.frame._page.browserContext._options.pierceFrames); - const { pierce, chunks } = splitSelectorByFrame(selector, pierceByDefault); + async resolveFramesForSelector(selector: string, options: types.StrictOptions & { anyFrame?: boolean } = {}, scope?: ElementHandle): Promise { + const { anyFrame, chunks } = splitSelectorByFrame(selector); for (const chunk of chunks) { visitAllSelectorParts(chunk, (part, nested) => { if (nested && part.name === 'internal:control' && part.body === 'enter-frame') { const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`); } - if (nested && pierce) { - const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); - throw new InvalidSelectorError(`Composite locators are not supported with piercing frames, while querying "${locator}"`); - } }); } - if (pierce) { - const parsed = chunks[0]; // Only one chunk is allowed with pierce, it may contain enter-frame parts. - if (parsed.parts.some((part, index) => part.name === 'nth' && index !== parsed.parts.length - 1)) { - const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); - throw new InvalidSelectorError(`nth can only be the last locator when piercing frames, while querying "${locator}"`); - } - return await this._resolveFramePiercingSelector(parsed, options, scope); + if (!anyFrame && !options.anyFrame) { + const result = await this._resolveChainedSelector(this.frame, selector, options, chunks, scope, false /* noStall */); + return result ? [result] : []; + } + + const result: SelectorInFrame[] = []; + const seenFrames = new Set(); + for (const startFrame of await this._anyFrameCandidates(scope)) { + const resolved = await this._resolveChainedSelector(startFrame, selector, options, chunks, startFrame === this.frame ? scope : undefined, startFrame !== this.frame /* noStall */); + // Different starting frames may resolve into the same frame, e.g. with an aria-ref selector. + if (!resolved || seenFrames.has(resolved.frame)) + continue; + seenFrames.add(resolved.frame); + result.push(resolved); + } + return result; + } + + // All the frames the selector may start in: the frame subtree, restricted to the scope when given. + private async _anyFrameCandidates(scope: ElementHandle | undefined): Promise { + const result: Frame[] = []; + const collectSubtree = (frame: Frame) => { + result.push(frame); + for (const child of frame.childFrames()) + collectSubtree(child); + }; + + if (!scope) { + collectSubtree(this.frame); + return result; } - const result = await this._resolveChainedSelector(selector, options, chunks, scope); - return result ? [result] : []; + result.push(this.frame); + await this.frame.raceAgainstEvaluationStallingEvents(async () => { + const injected = await scope._context.injectedScript(); + const frameElements = await injected.evaluateHandle((injected, scope) => { + return injected.querySelectorAll(injected.parseSelector('css=frame,iframe'), scope); + }, scope); + const count = await frameElements.evaluate(elements => elements.length); + for (let i = 0; i < count; ++i) { + const frameElement = await frameElements.evaluateHandle((elements, i) => elements[i], i) as ElementHandle; + const childFrame = await this.frame._page.delegate.getContentFrame(frameElement); + frameElement.dispose(); + if (childFrame) + collectSubtree(childFrame); + } + frameElements.dispose(); + }).catch(() => {}); + return result; } - private async _resolveChainedSelector(selector: string, options: types.StrictOptions, frameChunks: ParsedSelector[], scope: ElementHandle | undefined): Promise { - let frame: Frame = this.frame; + private async _resolveChainedSelector(startFrame: Frame, selector: string, options: types.StrictOptions, frameChunks: ParsedSelector[], scope: ElementHandle | undefined, noStall: boolean): Promise { + let frame = startFrame; for (let i = 0; i < frameChunks.length - 1; ++i) { const info = this._parseSelector(frameChunks[i], options); frame = this._jumpToAriaRefFrameIfNeeded(selector, info, frame); - const context = await frame.context(info.world); - const injectedScript = await context.injectedScript(); - const handle = await injectedScript.evaluateHandle((injected, { info, scope, selectorString }) => { - const element = injected.querySelector(info.parsed, scope || document, info.strict); - if (element && element.nodeName !== 'IFRAME' && element.nodeName !== 'FRAME') - throw injected.createStacklessError(`Selector "${selectorString}" resolved to ${injected.previewNode(element)}, `); - await expect.poll(() => page.frames().length).toBe(2); - await page.frames()[1].waitForSelector('button', { state: 'attached' }); - await expect(page.locator('button')).toHaveCount(0); -}); - -it.describe('pierceFrames context option', () => { - it.use({ - contextOptions: async ({ contextOptions }, use) => { - await use({ ...contextOptions, pierceFrames: true }); - } - }); - - it('should pierce frames by default', async ({ page }) => { - await page.setContent(``); - await expect(page.locator('button')).toHaveText('Click me'); - await page.locator('button').click(); - expect(await page.evaluate(() => (window as any).__clicked)).toBe(true); - }); - - it('should pierce nested frames by default', async ({ page }) => { - await page.setContent(``); - await expect(page.locator('#target')).toHaveText('deep'); - }); - - it('should fail when elements match in multiple frames', async ({ page }) => { - await page.setContent(`
main
`); - await expect.poll(() => page.frames().length).toBe(2); - await page.frames()[1].waitForSelector('div', { state: 'attached' }); - const error = await page.locator('div').count().catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); - }); - - it('should opt out with pierceFrames({ pierce: false })', async ({ page }) => { - await page.setContent(`
main
`); - await expect(page.locator('#inner')).toHaveText('inner'); - await expect(page.pierceFrames({ pierce: false }).locator('#inner')).toHaveCount(0); - await expect(page.pierceFrames({ pierce: false }).locator('#main')).toHaveText('main'); - }); - - it('should support frameLocator with the context option', async ({ page }) => { - await page.setContent(``); - await expect(page.frameLocator('#target').locator('button')).toHaveText('hi'); - }); - - it('should support frameLocator chains with pierceFrames({ pierce: false })', async ({ page }) => { - await page.setContent(``); - await expect(page.pierceFrames({ pierce: false }).frameLocator('#direct').locator('button')).toHaveText('direct'); - }); - - it('should resolve aria-ref selectors', async ({ page }) => { - await page.setContent(``); - await expect.poll(() => page.frames().length).toBe(2); - await page.frames()[1].waitForSelector('button', { state: 'attached' }); - const snapshot = await page.ariaSnapshot({ mode: 'ai' }); - const mainMatch = snapshot.match(/button "main" \[ref=(.*?)\]/); - await expect(page.locator(`aria-ref=${mainMatch![1]}`)).toHaveText('main'); - // Refs are unique across frames, so piercing resolves them in the right frame. - const insideMatch = snapshot.match(/button "inside" \[ref=(.*?)\]/); - expect(insideMatch![1]).toMatch(/^f\d+e\d+$/); - await expect(page.locator(`aria-ref=${insideMatch![1]}`)).toHaveText('inside'); - }); - - it('should support ai snapshot with nested frames', async ({ page }) => { - await page.setContent(``); - await expect.poll(() => page.frames().length).toBe(3); - await expect.poll(() => page.ariaSnapshot({ mode: 'ai' })).toContain('button "deep"'); - }); -}); diff --git a/tests/library/inspector/cli-codegen-3.spec.ts b/tests/library/inspector/cli-codegen-3.spec.ts index 5d3852b89ce1b..20b31affac1f9 100644 --- a/tests/library/inspector/cli-codegen-3.spec.ts +++ b/tests/library/inspector/cli-codegen-3.spec.ts @@ -154,39 +154,31 @@ await page.Locator("#frame1").ContentFrame.GetByText("Hello1").ClickAsync();`); frameHello2.click('text=Hello2'), ]); + // "Hello2" is unique across frames, so anyFrame() replaces two frameLocator() calls. expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.locator('#frame1').contentFrame().locator('iframe').contentFrame().getByText('Hello2').click();`); + await page.anyFrame().getByText('Hello2').click();`); expect.soft(sources.get('Java')!.text).toContain(` - page.locator("#frame1").contentFrame().locator("iframe").contentFrame().getByText("Hello2").click();`); + page.anyFrame().getByText("Hello2").click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.locator("#frame1").content_frame.locator("iframe").content_frame.get_by_text("Hello2").click()`); + page.any_frame.get_by_text("Hello2").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.locator("#frame1").content_frame.locator("iframe").content_frame.get_by_text("Hello2").click()`); + await page.any_frame.get_by_text("Hello2").click()`); expect.soft(sources.get('C#')!.text).toContain(` -await page.Locator("#frame1").ContentFrame.Locator("iframe").ContentFrame.GetByText("Hello2").ClickAsync();`); +await page.AnyFrame.GetByText("Hello2").ClickAsync();`); const clickAction = sources.get('JSON')!.actions.map(l => JSON.parse(l)).find(a => a.name === 'click'); expect.soft(clickAction).toEqual({ name: 'click', - selector: '#frame1 >> internal:control=enter-frame >> iframe >> internal:control=enter-frame >> internal:text="Hello2"i', + selector: 'internal:control=any-frame >> internal:text="Hello2"i', button: 'left', clickCount: 1, locator: { - body: '#frame1', kind: 'default', options: {}, - next: { - body: '', kind: 'frame', options: {}, - next: { - body: 'iframe', kind: 'default', options: {}, - next: { - body: '', kind: 'frame', options: {}, - next: { body: 'Hello2', kind: 'text', options: { exact: false } }, - }, - }, - }, + body: '', kind: 'any-frame', options: {}, + next: { body: 'Hello2', kind: 'text', options: { exact: false } }, }, modifiers: 0, signals: [], @@ -204,47 +196,29 @@ await page.Locator("#frame1").ContentFrame.Locator("iframe").ContentFrame.GetByT ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.locator('#frame1').contentFrame().locator('iframe').contentFrame().locator('iframe').nth(2).contentFrame().getByText('HelloNameAnonymous').click();`); + await page.anyFrame().getByText('HelloNameAnonymous').click();`); expect.soft(sources.get('Java')!.text).toContain(` - page.locator("#frame1").contentFrame().locator("iframe").contentFrame().locator("iframe").nth(2).contentFrame().getByText("HelloNameAnonymous").click();`); + page.anyFrame().getByText("HelloNameAnonymous").click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.locator("#frame1").content_frame.locator("iframe").content_frame.locator("iframe").nth(2).content_frame.get_by_text("HelloNameAnonymous").click()`); + page.any_frame.get_by_text("HelloNameAnonymous").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.locator("#frame1").content_frame.locator("iframe").content_frame.locator("iframe").nth(2).content_frame.get_by_text("HelloNameAnonymous").click()`); + await page.any_frame.get_by_text("HelloNameAnonymous").click()`); expect.soft(sources.get('C#')!.text).toContain(` -await page.Locator("#frame1").ContentFrame.Locator("iframe").ContentFrame.Locator("iframe").Nth(2).ContentFrame.GetByText("HelloNameAnonymous").ClickAsync();`); +await page.AnyFrame.GetByText("HelloNameAnonymous").ClickAsync();`); const clickAction = sources.get('JSON')!.actions.map(l => JSON.parse(l)).find(a => a.name === 'click'); expect.soft(clickAction).toEqual({ name: 'click', - selector: '#frame1 >> internal:control=enter-frame >> iframe >> internal:control=enter-frame >> iframe >> nth=2 >> internal:control=enter-frame >> internal:text="HelloNameAnonymous"i', + selector: 'internal:control=any-frame >> internal:text="HelloNameAnonymous"i', button: 'left', clickCount: 1, locator: { - body: '#frame1', kind: 'default', options: {}, - next: { - body: '', kind: 'frame', options: {}, - next: { - body: 'iframe', kind: 'default', options: {}, - next: { - body: '', kind: 'frame', options: {}, - next: { - body: 'iframe', kind: 'default', options: {}, - next: { - body: '2', kind: 'nth', options: {}, - next: { - body: '', kind: 'frame', options: {}, - next: { body: 'HelloNameAnonymous', kind: 'text', options: { exact: false } }, - }, - }, - }, - }, - }, - }, + body: '', kind: 'any-frame', options: {}, + next: { body: 'HelloNameAnonymous', kind: 'text', options: { exact: false } }, }, modifiers: 0, signals: [], @@ -300,20 +274,21 @@ await page.Locator("#frame1").ContentFrame.GetByText("Hello1").ClickAsync();`); frameHello2.click('text=Hello2'), ]); + // "Hello2" is unique across frames, so anyFrame() replaces two frameLocator() calls. expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.locator('#frame1').contentFrame().locator('iframe').contentFrame().getByText('Hello2').click();`); + await page.anyFrame().getByText('Hello2').click();`); expect.soft(sources.get('Java')!.text).toContain(` - page.locator("#frame1").contentFrame().locator("iframe").contentFrame().getByText("Hello2").click();`); + page.anyFrame().getByText("Hello2").click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.locator("#frame1").content_frame.locator("iframe").content_frame.get_by_text("Hello2").click()`); + page.any_frame.get_by_text("Hello2").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.locator("#frame1").content_frame.locator("iframe").content_frame.get_by_text("Hello2").click()`); + await page.any_frame.get_by_text("Hello2").click()`); expect.soft(sources.get('C#')!.text).toContain(` -await page.Locator("#frame1").ContentFrame.Locator("iframe").ContentFrame.GetByText("Hello2").ClickAsync();`); +await page.AnyFrame.GetByText("Hello2").ClickAsync();`); [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'HelloNameAnonymous'), @@ -321,19 +296,19 @@ await page.Locator("#frame1").ContentFrame.Locator("iframe").ContentFrame.GetByT ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.locator('#frame1').contentFrame().locator('iframe').contentFrame().locator('iframe').nth(2).contentFrame().getByText('HelloNameAnonymous').click();`); + await page.anyFrame().getByText('HelloNameAnonymous').click();`); expect.soft(sources.get('Java')!.text).toContain(` - page.locator("#frame1").contentFrame().locator("iframe").contentFrame().locator("iframe").nth(2).contentFrame().getByText("HelloNameAnonymous").click();`); + page.anyFrame().getByText("HelloNameAnonymous").click();`); expect.soft(sources.get('Python')!.text).toContain(` - page.locator("#frame1").content_frame.locator("iframe").content_frame.locator("iframe").nth(2).content_frame.get_by_text("HelloNameAnonymous").click()`); + page.any_frame.get_by_text("HelloNameAnonymous").click()`); expect.soft(sources.get('Python Async')!.text).toContain(` - await page.locator("#frame1").content_frame.locator("iframe").content_frame.locator("iframe").nth(2).content_frame.get_by_text("HelloNameAnonymous").click()`); + await page.any_frame.get_by_text("HelloNameAnonymous").click()`); expect.soft(sources.get('C#')!.text).toContain(` -await page.Locator("#frame1").ContentFrame.Locator("iframe").ContentFrame.Locator("iframe").Nth(2).ContentFrame.GetByText("HelloNameAnonymous").ClickAsync();`); +await page.AnyFrame.GetByText("HelloNameAnonymous").ClickAsync();`); }); test('should generate frame locators with special characters in name attribute', async ({ openRecorder, server }) => { @@ -1146,26 +1121,7 @@ await page.GetByTestId("testid").HoverAsync();`); }); }); -test.describe('cli codegen with pierceFrames', () => { - test.use({ - contextOptions: async ({ contextOptions }, use) => { - await use({ ...contextOptions, pierceFrames: true }); - }, - }); - - test('should generate a short selector when unique across frames', async ({ openRecorder, server }) => { - const { page, recorder } = await openRecorder(); - const { frameHello2 } = await createFrameHierarchy(page, recorder, server); - - const [sources] = await Promise.all([ - recorder.waitForOutput('JavaScript', 'Hello2'), - frameHello2.click('text=Hello2'), - ]); - - expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.getByText('Hello2').click();`); - }); - +test.describe('cli codegen with anyFrame', () => { test('should prepend the frame selector to disambiguate', async ({ openRecorder, server }) => { const { page, recorder } = await openRecorder(); await recorder.setContentAndWait(` @@ -1173,8 +1129,8 @@ test.describe('cli codegen with pierceFrames', () => { `, server.EMPTY_PAGE, 5); - // Both the bare selector and the "iframe >> enter-frame" prefix are ambiguous, - // so two frame selectors must be prepended to pinpoint the target frame. + // The bare selector is ambiguous across frames, so anyFrame() cannot pinpoint + // the target frame and the full frame chain is used instead. const frame = page.mainFrame().childFrames()[0].childFrames()[0]; const [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'click'), @@ -1183,22 +1139,27 @@ test.describe('cli codegen with pierceFrames', () => { expect.soft(sources.get('JavaScript')!.text).toContain(` await page.locator('#frame1').contentFrame().locator('iframe').contentFrame().getByRole('button', { name: 'Hello' }).click();`); + + const clickAction = sources.get('JSON')!.actions.map(l => JSON.parse(l)).find(a => a.name === 'click'); + expect.soft(clickAction.selector).toBe('#frame1 >> internal:control=enter-frame >> iframe >> internal:control=enter-frame >> internal:role=button[name="Hello"i]'); }); - test('should opt out of piercing when the frame chain does not pinpoint the frame', async ({ openRecorder, server }) => { + test('should not use anyFrame when it saves a single frameLocator', async ({ openRecorder, server }) => { const { page, recorder } = await openRecorder(); await recorder.setContentAndWait(` - - `, server.EMPTY_PAGE, 3); + + `, server.EMPTY_PAGE, 2); + // "Hello" is unique across frames, but anyFrame() would only replace a single + // frameLocator() call, so the plain frame chain is not any longer. const frame = page.mainFrame().childFrames()[0]; const [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'click'), - frame.click('internal:control=no-pierce-frames >> text=Hello'), + frame.click('text=Hello'), ]); expect.soft(sources.get('JavaScript')!.text).toContain(` - await page.pierceFrames({ pierce: false }).locator('#frame1').contentFrame().getByRole('button', { name: 'Hello' }).click();`); + await page.locator('#frame1').contentFrame().getByRole('button', { name: 'Hello' }).click();`); }); }); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 8cc0aec6b6a92..394291a8c3e3f 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -442,20 +442,20 @@ it('reverse engineer frameLocator', async ({ page }) => { expect.soft(asLocator('javascript', selector)).toBe(`locator('div').locator('iframe').contentFrame().locator('span')`); }); -it('reverse engineer pierceFrames', async ({ page }) => { - expect.soft(generate(page.pierceFrames().getByText('foo').locator('span'))).toEqual({ - csharp: `PierceFrames.GetByText("foo").Locator("span")`, - java: `pierceFrames().getByText("foo").locator("span")`, - javascript: `pierceFrames().getByText('foo').locator('span')`, - python: `pierce_frames.get_by_text("foo").locator("span")`, +it('reverse engineer anyFrame', async ({ page }) => { + expect.soft(generate(page.anyFrame().getByText('foo').locator('span'))).toEqual({ + csharp: `AnyFrame.GetByText("foo").Locator("span")`, + java: `anyFrame().getByText("foo").locator("span")`, + javascript: `anyFrame().getByText('foo').locator('span')`, + python: `any_frame.get_by_text("foo").locator("span")`, }); - // Note that bare `pierce_frames` and `PierceFrames` are not restored back, + // Note that bare `any_frame` and `AnyFrame` are not restored back, // because they are indistinguishable from a css selector. - expect.soft(asLocator('javascript', 'internal:control=pierce-frames')).toBe(`pierceFrames()`); - expect.soft(asLocator('python', 'internal:control=pierce-frames')).toBe(`pierce_frames`); - expect.soft(asLocator('java', 'internal:control=pierce-frames')).toBe(`pierceFrames()`); - expect.soft(asLocator('csharp', 'internal:control=pierce-frames')).toBe(`PierceFrames`); + expect.soft(asLocator('javascript', 'internal:control=any-frame')).toBe(`anyFrame()`); + expect.soft(asLocator('python', 'internal:control=any-frame')).toBe(`any_frame`); + expect.soft(asLocator('java', 'internal:control=any-frame')).toBe(`anyFrame()`); + expect.soft(asLocator('csharp', 'internal:control=any-frame')).toBe(`AnyFrame`); }); it('generate multiple locators', async ({ page }) => { diff --git a/tests/page/locator-pierce-frames.spec.ts b/tests/page/locator-any-frame.spec.ts similarity index 63% rename from tests/page/locator-pierce-frames.spec.ts rename to tests/page/locator-any-frame.spec.ts index fbc444bcab7f3..ad171ea680f0f 100644 --- a/tests/page/locator-pierce-frames.spec.ts +++ b/tests/page/locator-any-frame.spec.ts @@ -24,7 +24,7 @@ function routePage(page: Page, url: string, body: string) { } async function waitForAllFrames(page: Page, frameCount: number, selector: string) { - // Wait for all child frames to load their content, so that piercing + // Wait for all child frames to load their content, so that the search // deterministically sees elements in all of them. await expect.poll(() => page.frames().length).toBe(frameCount); for (const frame of page.frames()) { @@ -37,7 +37,7 @@ it('should click a button inside an iframe', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); - await page.pierceFrames().getByRole('button', { name: 'Click me' }).click(); + await page.anyFrame().getByRole('button', { name: 'Click me' }).click(); expect(await page.frames()[1].evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -45,7 +45,7 @@ it('should click a button in the main frame', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
No buttons here
`); await page.goto(server.EMPTY_PAGE); - await page.pierceFrames().locator('button').click(); + await page.anyFrame().locator('button').click(); expect(await page.evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -55,9 +55,9 @@ it('should fail click when elements match in multiple frames', async ({ page, se await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'button'); - const error = await page.pierceFrames().locator('button').click({ timeout: 3000 }).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); - expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); + const error = await page.anyFrame().locator('button').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + expect(error.message).toContain(`waiting for anyFrame().locator('button')`); }); it('should fail click upon strict mode violation inside a single frame', async ({ page, server }) => { @@ -65,18 +65,18 @@ it('should fail click upon strict mode violation inside a single frame', async ( await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'button'); - const error = await page.pierceFrames().locator('button').click({ timeout: 3000 }).catch(e => e); + const error = await page.anyFrame().locator('button').click({ timeout: 3000 }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); + expect(error.message).toContain(`waiting for anyFrame().locator('button')`); }); it('should time out on click when there are no matches', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Nothing here
`); await page.goto(server.EMPTY_PAGE); - const error = await page.pierceFrames().locator('button').click({ timeout: 1000 }).catch(e => e); + const error = await page.anyFrame().locator('button').click({ timeout: 1000 }).catch(e => e); expect(error.message).toContain('Timeout 1000ms exceeded'); - expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); + expect(error.message).toContain(`waiting for anyFrame().locator('button')`); }); it('should count elements in a single frame', async ({ page, server }) => { @@ -84,8 +84,8 @@ it('should count elements in a single frame', async ({ page, server }) => { await routePage(page, 'a.html', `
1
2
3
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'div'); - expect(await page.pierceFrames().locator('div').count()).toBe(3); - expect(await page.pierceFrames().locator('button').count()).toBe(0); + expect(await page.anyFrame().locator('div').count()).toBe(3); + expect(await page.anyFrame().locator('button').count()).toBe(0); }); it('should fail count when elements match in multiple frames', async ({ page, server }) => { @@ -93,16 +93,16 @@ it('should fail count when elements match in multiple frames', async ({ page, se await routePage(page, 'a.html', `
child
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'div'); - const error = await page.pierceFrames().locator('div').count().catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + const error = await page.anyFrame().locator('div').count().catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); }); it('should support toHaveCount', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `onetwo`); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('span')).toHaveCount(2); - await expect(page.pierceFrames().locator('button')).toHaveCount(0); + await expect(page.anyFrame().locator('span')).toHaveCount(2); + await expect(page.anyFrame().locator('button')).toHaveCount(0); }); it('should wait for a frame to appear with toHaveCount', async ({ page, server }) => { @@ -116,7 +116,7 @@ it('should wait for a frame to appear with toHaveCount', async ({ page, server } document.body.appendChild(iframe); }, 500); }); - await expect(page.pierceFrames().locator('span')).toHaveCount(2); + await expect(page.anyFrame().locator('span')).toHaveCount(2); }); it('should fail toHaveCount when elements match in multiple frames', async ({ page, server }) => { @@ -125,23 +125,23 @@ it('should fail toHaveCount when elements match in multiple frames', async ({ pa await routePage(page, 'b.html', `two`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'span'); - const error = await expect(page.pierceFrames().locator('span')).toHaveCount(2, { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); - expect(error.message).toContain(`Locator: pierceFrames().locator('span')`); + const error = await expect(page.anyFrame().locator('span')).toHaveCount(2, { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + expect(error.message).toContain(`Locator: anyFrame().locator('span')`); }); it('should support toHaveText', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Hello iframe
`); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('div')).toHaveText('Hello iframe'); + await expect(page.anyFrame().locator('div')).toHaveText('Hello iframe'); }); it('should support toHaveText with an array', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `onetwo`); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('span')).toHaveText(['one', 'two']); + await expect(page.anyFrame().locator('span')).toHaveText(['one', 'two']); }); it('should fail toHaveText when elements match in multiple frames', async ({ page, server }) => { @@ -150,9 +150,9 @@ it('should fail toHaveText when elements match in multiple frames', async ({ pag await routePage(page, 'b.html', `
two
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'div'); - const error = await expect(page.pierceFrames().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); - expect(error.message).toContain(`Locator: pierceFrames().locator('div')`); + const error = await expect(page.anyFrame().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + expect(error.message).toContain(`Locator: anyFrame().locator('div')`); }); it('should fail toHaveText with an array when elements match in multiple frames', async ({ page, server }) => { @@ -161,8 +161,8 @@ it('should fail toHaveText with an array when elements match in multiple frames' await routePage(page, 'b.html', `two`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'span'); - const error = await expect(page.pierceFrames().locator('span')).toHaveText(['one', 'two'], { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + const error = await expect(page.anyFrame().locator('span')).toHaveText(['one', 'two'], { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); }); it('should fail toHaveText upon strict mode violation inside a single frame', async ({ page, server }) => { @@ -170,16 +170,16 @@ it('should fail toHaveText upon strict mode violation inside a single frame', as await routePage(page, 'a.html', `
one
two
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'div'); - const error = await expect(page.pierceFrames().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); + const error = await expect(page.anyFrame().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain(`Locator: pierceFrames().locator('div')`); + expect(error.message).toContain(`Locator: anyFrame().locator('div')`); }); it('should support evaluate', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Hello
`); await page.goto(server.EMPTY_PAGE); - expect(await page.pierceFrames().locator('div').evaluate(e => e.getAttribute('data-foo'))).toBe('bar'); + expect(await page.anyFrame().locator('div').evaluate(e => e.getAttribute('data-foo'))).toBe('bar'); }); it('should fail evaluate when elements match in multiple frames', async ({ page, server }) => { @@ -188,17 +188,17 @@ it('should fail evaluate when elements match in multiple frames', async ({ page, await routePage(page, 'b.html', `
two
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'div'); - const error = await page.pierceFrames().locator('div').evaluate(e => e.textContent, undefined, { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + const error = await page.anyFrame().locator('div').evaluate(e => e.textContent, undefined, { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); }); it('should time out on evaluate when there are no matches', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Nothing here
`); await page.goto(server.EMPTY_PAGE); - const error = await page.pierceFrames().locator('button').evaluate(e => e.textContent, undefined, { timeout: 1000 }).catch(e => e); + const error = await page.anyFrame().locator('button').evaluate(e => e.textContent, undefined, { timeout: 1000 }).catch(e => e); expect(error.message).toContain('Timeout 1000ms exceeded'); - expect(error.message).toContain(`waiting for pierceFrames().locator('button')`); + expect(error.message).toContain(`waiting for anyFrame().locator('button')`); }); it('should support evaluateAll', async ({ page, server }) => { @@ -206,8 +206,8 @@ it('should support evaluateAll', async ({ page, server }) => { await routePage(page, 'a.html', `onetwo`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'span'); - expect(await page.pierceFrames().locator('span').evaluateAll(els => els.map(e => e.textContent))).toEqual(['one', 'two']); - expect(await page.pierceFrames().locator('button').evaluateAll(els => els.length)).toBe(0); + expect(await page.anyFrame().locator('span').evaluateAll(els => els.map(e => e.textContent))).toEqual(['one', 'two']); + expect(await page.anyFrame().locator('button').evaluateAll(els => els.length)).toBe(0); }); it('should fail evaluateAll when elements match in multiple frames', async ({ page, server }) => { @@ -216,39 +216,60 @@ it('should fail evaluateAll when elements match in multiple frames', async ({ pa await routePage(page, 'b.html', `two`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'span'); - const error = await page.pierceFrames().locator('span').evaluateAll(els => els.length).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + const error = await page.anyFrame().locator('span').evaluateAll(els => els.length).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); }); it('should support hasText filter', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
foo
bar
`); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('div', { hasText: 'bar' })).toHaveText('bar'); + await expect(page.anyFrame().locator('div', { hasText: 'bar' })).toHaveText('bar'); }); -it('should support first/last/nth as the last operation', async ({ page, server }) => { +it('should support first/last/nth', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `onetwothree`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'span'); - await expect(page.pierceFrames().locator('span').first()).toHaveText('one'); - await expect(page.pierceFrames().locator('span').last()).toHaveText('three'); - await expect(page.pierceFrames().locator('span').nth(1)).toHaveText('two'); + await expect(page.anyFrame().locator('span').first()).toHaveText('one'); + await expect(page.anyFrame().locator('span').last()).toHaveText('three'); + await expect(page.anyFrame().locator('span').nth(1)).toHaveText('two'); }); -it('should pierce a frame inside the scope', async ({ page, server }) => { +it('should support nth in the middle of the chain', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
one
two
`); + await page.goto(server.EMPTY_PAGE); + await expect(page.anyFrame().locator('div').nth(1).locator('span')).toHaveText('two'); +}); + +it('should support composite locators', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
foo
bar
`); + await page.goto(server.EMPTY_PAGE); + await expect(page.anyFrame().locator('div', { has: page.locator('span') })).toHaveText('foo'); +}); + +it('should support capture', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
hello
`); + await page.goto(server.EMPTY_PAGE); + await expect(page.anyFrame().locator('*css=div >> span')).toHaveAttribute('id', 'target'); +}); + +it('should find a frame inside the scope', async ({ page, server }) => { await routePage(page, 'empty.html', `
`); await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'button'); const scope = (await page.$('section'))!; - const buttons = await scope.$$('internal:control=pierce-frames >> button'); + const buttons = await scope.$$('internal:control=any-frame >> button'); expect(buttons.length).toBe(1); expect(await buttons[0].textContent()).toBe('inside'); }); -it('should pierce a nested frame inside the scope', async ({ page, server }) => { +it('should find a nested frame inside the scope', async ({ page, server }) => { await routePage(page, 'empty.html', `
`); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); @@ -260,23 +281,35 @@ it('should pierce a nested frame inside the scope', async ({ page, server }) => await frame.waitForSelector('button', { state: 'attached' }); } const scope = (await page.$('section'))!; - const buttons = await scope.$$('internal:control=pierce-frames >> button'); + const buttons = await scope.$$('internal:control=any-frame >> button'); expect(buttons.length).toBe(1); expect(await buttons[0].textContent()).toBe('deep'); }); +it('should find a frame inside the scope while another iframe is stalled', async ({ page, server }) => { + await routePage(page, 'empty.html', `
`); + await routePage(page, 'a.html', ``); + await page.route('**/stall.html', () => {}); + await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => page.frames().length).toBe(3); + const scope = (await page.$('section'))!; + const buttons = await scope.$$('internal:control=any-frame >> button'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('inside'); +}); + it('should respect the scope without a frame inside the scope', async ({ page, server }) => { await routePage(page, 'empty.html', `
`); await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'button'); const scope = (await page.$('section'))!; - const buttons = await scope.$$('internal:control=pierce-frames >> button'); + const buttons = await scope.$$('internal:control=any-frame >> button'); expect(buttons.length).toBe(1); expect(await buttons[0].textContent()).toBe('target'); }); -it('should pierce nested frames below a matching prefix', async ({ page, server }) => { +it('should not match a chain across a frame boundary', async ({ page, server }) => { await routePage(page, 'empty.html', `
`); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); @@ -284,10 +317,12 @@ it('should pierce nested frames below a matching prefix', async ({ page, server await expect.poll(() => page.frames().length).toBe(3); const deepFrame = page.frames().find(f => f.url().includes('b.html'))!; await deepFrame.waitForSelector('button', { state: 'attached' }); - await expect(page.pierceFrames().locator('section').locator('button')).toHaveText('deep'); + // "section" lives in the main frame, while the button is two frames below it. + await expect(page.anyFrame().locator('section').locator('button')).toHaveCount(0); + await expect(page.anyFrame().locator('button')).toHaveText('deep'); }); -it('should pierce only frames inside the starting frame', async ({ page, server }) => { +it('should only search frames inside the starting frame', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); @@ -296,7 +331,7 @@ it('should pierce only frames inside the starting frame', async ({ page, server const deepFrame = page.frames().find(f => f.url().includes('b.html'))!; await deepFrame.waitForSelector('button', { state: 'attached' }); const middleFrame = page.frames().find(f => f.url().includes('a.html'))!; - await expect(middleFrame.pierceFrames().locator('button')).toHaveText('deep'); + await expect(middleFrame.anyFrame().locator('button')).toHaveText('deep'); }); it('should enter a frame found in a nested frame', async ({ page, server }) => { @@ -304,7 +339,7 @@ it('should enter a frame found in a nested frame', async ({ page, server }) => { await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().frameLocator('#target').locator('button')).toHaveText('inside'); + await expect(page.anyFrame().frameLocator('#target').locator('button')).toHaveText('inside'); }); it('should click inside an entered frame', async ({ page, server }) => { @@ -312,43 +347,45 @@ it('should click inside an entered frame', async ({ page, server }) => { await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await page.pierceFrames().frameLocator('#target').getByRole('button', { name: 'Click me' }).click(); + await page.anyFrame().frameLocator('#target').getByRole('button', { name: 'Click me' }).click(); const frame = page.frames().find(f => f.url().includes('b.html'))!; expect(await frame.evaluate(() => (window as any).__clicked)).toBe(true); }); -it('should pierce frames inside the entered frame', async ({ page, server }) => { +it('should not search nested frames after entering a frame', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().frameLocator('#target').locator('button')).toHaveText('deep'); + await expect.poll(() => page.frames().length).toBe(3); + // The entered frame itself has no button, and we do not look inside its nested frames. + await expect(page.anyFrame().frameLocator('#target').locator('button')).toHaveCount(0); }); -it('should support two frameLocators while piercing', async ({ page, server }) => { +it('should support two frameLocators', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await routePage(page, 'c.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().frameLocator('#x').frameLocator('#y').locator('button')).toHaveText('bottom'); + await expect(page.anyFrame().frameLocator('#x').frameLocator('#y').locator('button')).toHaveText('bottom'); }); -it('should support locator before frameLocator while piercing', async ({ page, server }) => { +it('should support locator before frameLocator', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
`); await routePage(page, 'b.html', ``); await routePage(page, 'c.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('section').frameLocator('iframe').locator('button')).toHaveText('in-section'); + await expect(page.anyFrame().locator('section').frameLocator('iframe').locator('button')).toHaveText('in-section'); }); -it('should support owner of a frameLocator while piercing', async ({ page, server }) => { +it('should support owner of a frameLocator', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - expect(await page.pierceFrames().frameLocator('#target').owner().getAttribute('id')).toBe('target'); + expect(await page.anyFrame().frameLocator('#target').owner().getAttribute('id')).toBe('target'); }); it('should wait for the frame to enter to appear', async ({ page, server }) => { @@ -365,7 +402,7 @@ it('should wait for the frame to enter to appear', async ({ page, server }) => { document.body.appendChild(iframe); }, 3000); }); - await expect(page.pierceFrames().frameLocator('#late').locator('button')).toHaveText('late'); + await expect(page.anyFrame().frameLocator('#late').locator('button')).toHaveText('late'); }); it('should fail when the frame to enter matches in multiple frames', async ({ page, server }) => { @@ -379,80 +416,44 @@ it('should fail when the frame to enter matches in multiple frames', async ({ pa if (frame.url().includes('c.html')) await frame.waitForSelector('button', { state: 'attached' }); } - const error = await page.pierceFrames().frameLocator('.inner').locator('button').click({ timeout: 3000 }).catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + const error = await page.anyFrame().frameLocator('.inner').locator('button').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); }); -it('should support contentFrame while piercing', async ({ page, server }) => { +it('should support contentFrame', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('#target').contentFrame().locator('button')).toHaveText('inside'); + await expect(page.anyFrame().locator('#target').contentFrame().locator('button')).toHaveText('inside'); }); -it('should enter only the frame element when the selector matches other elements too', async ({ page, server }) => { - await routePage(page, 'empty.html', `
not a frame
`); - await routePage(page, 'a.html', ``); - await routePage(page, 'b.html', `
found
`); - await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames().locator('.foo').contentFrame().locator('#target')).toHaveText('found'); +it('should render anyFrame in the locator description', async ({ page }) => { + expect(String(page.anyFrame().frameLocator('#x').locator('button'))).toBe(`anyFrame().locator('#x').contentFrame().locator('button')`); + expect(String(page.anyFrame().locator('section').frameLocator('iframe').getByText('foo'))).toBe(`anyFrame().locator('section').locator('iframe').contentFrame().getByText('foo')`); }); -it('should render frameLocator while piercing in the locator description', async ({ page }) => { - expect(String(page.pierceFrames().frameLocator('#x').locator('button'))).toBe(`pierceFrames().locator('#x').contentFrame().locator('button')`); - expect(String(page.pierceFrames().locator('section').frameLocator('iframe').getByText('foo'))).toBe(`pierceFrames().locator('section').locator('iframe').contentFrame().getByText('foo')`); +it('should not allow first/last/nth on anyFrame', async ({ page }) => { + expect(() => page.anyFrame().first()).toThrow('Selecting the nth frame is not allowed on anyFrame()'); + expect(() => page.anyFrame().last()).toThrow('Selecting the nth frame is not allowed on anyFrame()'); + expect(() => page.anyFrame().nth(1)).toThrow('Selecting the nth frame is not allowed on anyFrame()'); }); -it('should support the pierce option', async ({ page, server }) => { - await routePage(page, 'empty.html', `
main
`); - await routePage(page, 'a.html', `
inner
`); - await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames({ pierce: true }).locator('#inner')).toHaveText('inner'); - await expect(page.pierceFrames({ pierce: false }).locator('#inner')).toHaveCount(0); - await expect(page.pierceFrames({ pierce: false }).locator('#main')).toHaveText('main'); +it('should not allow owner on anyFrame', async ({ page }) => { + const error = await page.anyFrame().owner().count().catch(e => e); + expect(error.message).toContain('Selector cannot be empty after anyFrame()'); }); -it('should support frameLocator with pierce: false', async ({ page, server }) => { - await routePage(page, 'empty.html', ``); +it('should resolve aria-ref selectors', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.pierceFrames({ pierce: false }).frameLocator('iframe').locator('button')).toHaveText('inside'); -}); - -it('should render pierce: false in the locator description', async ({ page }) => { - expect(String(page.pierceFrames({ pierce: false }).locator('div'))).toBe(`pierceFrames({ pierce: false }).locator('div')`); -}); - -it('should not allow first/last/nth after pierceFrames({ pierce: false })', async ({ page }) => { - expect(() => page.pierceFrames({ pierce: false }).first()).toThrow('Selecting the nth frame is not allowed on the root frame locator'); -}); - -it('should not allow nth in the middle', async ({ page }) => { - const error = await page.pierceFrames().locator('div').first().locator('span').count().catch(e => e); - expect(error.message).toContain(`nth can only be the last locator when piercing frames, while querying "pierceFrames().locator('div').first().locator('span')"`); -}); - -it('should not allow first/last/nth after pierceFrames', async ({ page }) => { - expect(() => page.pierceFrames().first()).toThrow('Selecting the nth frame is not allowed while piercing frames'); - expect(() => page.pierceFrames().last()).toThrow('Selecting the nth frame is not allowed while piercing frames'); - expect(() => page.pierceFrames().nth(1)).toThrow('Selecting the nth frame is not allowed while piercing frames'); - expect(() => page.pierceFrames().frameLocator('#x').first()).toThrow('Selecting the nth frame is not allowed while piercing frames'); -}); - -it('should not allow composite locators', async ({ page }) => { - const error = await page.pierceFrames().locator('div', { has: page.locator('span') }).count().catch(e => e); - expect(error.message).toContain(`Composite locators are not supported with piercing frames, while querying "pierceFrames().locator('div').filter({ has: locator('span') })"`); -}); - -it('should not allow owner', async ({ page }) => { - const error = await page.pierceFrames().owner().count().catch(e => e); - expect(error.message).toContain('Selector cannot be empty when piercing frames'); -}); - -it('should render pierceFrames in the locator description', async ({ page }) => { - expect(String(page.pierceFrames().locator('button'))).toBe(`pierceFrames().locator('button')`); - expect(String(page.pierceFrames().getByText('foo'))).toBe(`pierceFrames().getByText('foo')`); + await waitForAllFrames(page, 2, 'button'); + const snapshot = await page.ariaSnapshot({ mode: 'ai' }); + // Refs are unique across frames, so every starting frame resolves them to the same element. + const insideMatch = snapshot.match(/button "inside" \[ref=(.*?)\]/); + expect(insideMatch![1]).toMatch(/^f\d+e\d+$/); + await expect(page.anyFrame().locator(`aria-ref=${insideMatch![1]}`)).toHaveText('inside'); }); it('should click while another iframe is stalled', async ({ page, server }) => { @@ -461,7 +462,7 @@ it('should click while another iframe is stalled', async ({ page, server }) => { await page.route('**/stall.html', () => {}); await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); await expect.poll(() => page.frames().length).toBe(3); - await page.pierceFrames().locator('button').click(); + await page.anyFrame().locator('button').click(); const frame = page.frames().find(f => f.url().includes('a.html'))!; expect(await frame.evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -472,7 +473,7 @@ it('should support toBeVisible while another iframe is stalled', async ({ page, await page.route('**/stall.html', () => {}); await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); await expect.poll(() => page.frames().length).toBe(3); - await expect(page.pierceFrames().locator('button')).toBeVisible(); + await expect(page.anyFrame().locator('button')).toBeVisible(); }); it('should support toHaveCount while another iframe is stalled', async ({ page, server }) => { @@ -481,5 +482,5 @@ it('should support toHaveCount while another iframe is stalled', async ({ page, await page.route('**/stall.html', () => {}); await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); await expect.poll(() => page.frames().length).toBe(3); - await expect(page.pierceFrames().locator('button')).toHaveCount(1); + await expect(page.anyFrame().locator('button')).toHaveCount(1); }); diff --git a/tests/page/selectors-frame.spec.ts b/tests/page/selectors-frame.spec.ts index c12a6c38ff57b..4ffd0e7cb09c0 100644 --- a/tests/page/selectors-frame.spec.ts +++ b/tests/page/selectors-frame.spec.ts @@ -311,34 +311,32 @@ it('should non work for non-frame', async ({ page, server }) => { expect(error.message).toContain('', contentType: 'text/html' }).catch(() => {}); }); @@ -369,7 +367,7 @@ it('should throw when piercing frames matches multiple frames', async ({ page, s }); await page.goto(server.EMPTY_PAGE); - // Make sure both child frames have their
before piercing, otherwise resolution + // Make sure both child frames have their
before matching, otherwise resolution // may transiently collapse to a single frame. await expect.poll(() => page.frames().length).toBe(3); for (const frame of page.frames()) { @@ -377,31 +375,31 @@ it('should throw when piercing frames matches multiple frames', async ({ page, s await frame.waitForSelector('div'); } - const error = await page.locator('internal:control=pierce-frames >> div').innerHTML().catch(e => e); - expect(error.message).toContain('Pierce-frame mode matched elements from multiple frames'); + const error = await page.locator('internal:control=any-frame >> div').innerHTML().catch(e => e); + expect(error.message).toContain('anyFrame() matched elements in multiple frames'); }); -it('should not allow pierce-frames in the middle of a selector', async ({ page, server }) => { +it('should not allow any-frame in the middle of a selector', async ({ page, server }) => { await routeIframe(page); await page.goto(server.EMPTY_PAGE); - const error = await page.locator('iframe >> internal:control=pierce-frames >> div').waitFor().catch(e => e); - expect(error.message).toContain('"pierce-frames" is only allowed as the first selector token'); + const error = await page.locator('iframe >> internal:control=any-frame >> div').waitFor().catch(e => e); + expect(error.message).toContain('"any-frame" is only allowed as the first selector token'); }); -it('should allow entering frames while piercing', async ({ page, server }) => { +it('should allow entering frames from any frame', async ({ page, server }) => { await routeIframe(page); await page.goto(server.EMPTY_PAGE); - const button = page.locator('internal:control=pierce-frames >> iframe[src="iframe-2.html"] >> internal:control=enter-frame >> button'); + const button = page.locator('internal:control=any-frame >> iframe[src="iframe-2.html"] >> internal:control=enter-frame >> button'); await button.waitFor(); expect(await button.innerText()).toBe('Hello nested iframe'); }); -it('should not allow pierce-frames after entering a frame', async ({ page }) => { - const error = await page.locator('iframe >> internal:control=enter-frame >> internal:control=pierce-frames >> button').count().catch(e => e); - expect(error.message).toContain('"pierce-frames" is only allowed as the first selector token'); +it('should not allow any-frame after entering a frame', async ({ page }) => { + const error = await page.locator('iframe >> internal:control=enter-frame >> internal:control=any-frame >> button').count().catch(e => e); + expect(error.message).toContain('"any-frame" is only allowed as the first selector token'); }); -it('should not allow dangling enter-frame while piercing', async ({ page }) => { - const error = await page.locator('internal:control=pierce-frames >> iframe >> internal:control=enter-frame').count().catch(e => e); +it('should not allow dangling enter-frame after any-frame', async ({ page }) => { + const error = await page.locator('internal:control=any-frame >> iframe >> internal:control=enter-frame').count().catch(e => e); expect(error.message).toContain('Selector cannot end with entering frame'); }); diff --git a/tests/playwright-test/playwright.spec.ts b/tests/playwright-test/playwright.spec.ts index a9e2782db9ebe..d3214690c221e 100644 --- a/tests/playwright-test/playwright.spec.ts +++ b/tests/playwright-test/playwright.spec.ts @@ -1074,21 +1074,3 @@ test('should pause test timeout while on pause', async ({ runInlineTest }) => { }, { timeout: 3000 }); expect(result.exitCode).toBe(0); }); - -test('should support pierceFrames option', async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'playwright.config.ts': ` - module.exports = { use: { pierceFrames: true } }; - `, - 'a.test.ts': ` - import { test, expect } from '@playwright/test'; - test('pierces frames by default', async ({ page }) => { - await page.setContent(''); - await expect(page.locator('button')).toHaveText('inside'); - await expect(page.pierceFrames({ pierce: false }).locator('button')).toHaveCount(0); - }); - `, - }); - expect(result.exitCode).toBe(0); - expect(result.passed).toBe(1); -}); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index b56401433ec57..c65d5899e61ca 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -297,7 +297,6 @@ export interface PlaywrightTestOptions { locale: string | undefined; offline: boolean; permissions: string[] | undefined; - pierceFrames: boolean; proxy: Proxy | undefined; reducedMotion: ReducedMotion; storageState: StorageState | undefined; From b4490a8f49e42564eb3ff672c7d9c36621b45654 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 26 Aug 2026 09:15:42 +0100 Subject: [PATCH 2/4] fix(selectors): reject anyFrame() inside composite locators A nested "any-frame" token used to reach the injected control engine and match nothing, so page.locator('button').or(page.anyFrame().locator('a')) silently dropped the second operand. The token applies to the whole locator, so an operand never carries one: page.anyFrame().locator('.classname').or(page.getByRole('button')) matches ".classname or button" inside every frame. Skip a leading "any-frame" when matching the nested frame prefix, so that an operand may still repeat the frame chain, and reject any leftover token. --- packages/isomorphic/selectorParser.ts | 4 +- .../src/server/frameSelectors.ts | 4 ++ tests/page/locator-any-frame.spec.ts | 42 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index 97cc20e214480..f5234a313a3fe 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -75,8 +75,10 @@ export function parseSelector(selector: string): ParsedSelector { const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } }; const lastFrame = [...nested.body.parsed.parts].reverse().find(part => part.name === 'internal:control' && part.body === 'enter-frame'); const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1; + // The "any-frame" token applies to the whole selector, so nested selectors must not repeat it. + const outerParts = parts[0]?.name === 'internal:control' && parts[0].body === 'any-frame' ? parts.slice(1) : parts; // Allow nested selectors to start with the same frame selector. - if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1))) + if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), outerParts.slice(0, lastFrameIndex + 1))) nested.body.parsed.parts.splice(0, lastFrameIndex + 1); parts.push(nested); continue; diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index cf1b3d18e6882..3737b188e7d93 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -128,6 +128,10 @@ export class FrameSelectors { const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`); } + if (nested && part.name === 'internal:control' && part.body === 'any-frame') { + const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector); + throw new InvalidSelectorError(`anyFrame() is not allowed inside composite locators, while querying "${locator}"`); + } }); } diff --git a/tests/page/locator-any-frame.spec.ts b/tests/page/locator-any-frame.spec.ts index ad171ea680f0f..6c64ff3b878ec 100644 --- a/tests/page/locator-any-frame.spec.ts +++ b/tests/page/locator-any-frame.spec.ts @@ -433,6 +433,48 @@ it('should render anyFrame in the locator description', async ({ page }) => { expect(String(page.anyFrame().locator('section').frameLocator('iframe').getByText('foo'))).toBe(`anyFrame().locator('section').locator('iframe').contentFrame().getByText('foo')`); }); +it('should not allow anyFrame inside a composite locator', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `link`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'a'); + + const error = await page.locator('button').or(page.anyFrame().locator('a')).count().catch(e => e); + expect(error.message).toContain(`anyFrame() is not allowed inside composite locators, while querying "locator('button').or(anyFrame().locator('a'))"`); + + const error2 = await page.locator('button').filter({ has: page.anyFrame().locator('a') }).count().catch(e => e); + expect(error2.message).toContain(`anyFrame() is not allowed inside composite locators`); + + // Repeating anyFrame() in the operand is not allowed either, even though the outer locator has it. + const error3 = await page.anyFrame().locator('button').or(page.anyFrame().locator('a')).count().catch(e => e); + expect(error3.message).toContain(`anyFrame() is not allowed inside composite locators, while querying "anyFrame().locator('button').or(anyFrame().locator('a'))"`); + + // Repeating anyFrame() is not allowed even when the rest of the frame chain matches. + const error4 = await page.anyFrame().frameLocator('#f').locator('a').or(page.anyFrame().frameLocator('#f').locator('button')).count().catch(e => e); + expect(error4.message).toContain(`anyFrame() is not allowed inside composite locators`); + + // With anyFrame() first, the token applies to the whole locator, so both operands are searched in every frame. + const error5 = await page.anyFrame().locator('a').or(page.locator('button')).count().catch(e => e); + expect(error5.message).toContain(`anyFrame() matched elements in multiple frames`); +}); + +it('should support a composite locator under anyFrame', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', `
first
`); + await page.goto(server.EMPTY_PAGE); + await waitForAllFrames(page, 2, 'button'); + await expect(page.anyFrame().locator('.classname').or(page.getByRole('button'))).toHaveText(['first', 'second']); +}); + +it('should support a composite locator under anyFrame and a frame locator', async ({ page, server }) => { + await routePage(page, 'empty.html', ``); + await routePage(page, 'a.html', ``); + await routePage(page, 'b.html', `
first
`); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.frames().length).toBe(3); + await expect(page.anyFrame().frameLocator('#f').locator('.classname').or(page.frameLocator('#f').getByRole('button'))).toHaveText(['first', 'second']); +}); + it('should not allow first/last/nth on anyFrame', async ({ page }) => { expect(() => page.anyFrame().first()).toThrow('Selecting the nth frame is not allowed on anyFrame()'); expect(() => page.anyFrame().last()).toThrow('Selecting the nth frame is not allowed on anyFrame()'); From 9ca7a8e295cd9eecac50cb57264ab1c8565aeb84 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 27 Aug 2026 10:25:08 +0100 Subject: [PATCH 3/4] fix(selectors): shift the capture when removing a common frame prefix parseSelector() strips the frame prefix a nested selector shares with the outer one, but left "capture" pointing at the old part index, so frameLocator('#f').locator('body') .locator(frameLocator('#f').locator('*css=section >> span')) resolved to the span instead of the section. Shift the capture along with the removed parts, and reject a capture that falls inside the prefix. --- packages/isomorphic/selectorParser.ts | 9 ++++++++- tests/page/locator-query.spec.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/isomorphic/selectorParser.ts b/packages/isomorphic/selectorParser.ts index f5234a313a3fe..906cfa79d3a93 100644 --- a/packages/isomorphic/selectorParser.ts +++ b/packages/isomorphic/selectorParser.ts @@ -78,8 +78,15 @@ export function parseSelector(selector: string): ParsedSelector { // The "any-frame" token applies to the whole selector, so nested selectors must not repeat it. const outerParts = parts[0]?.name === 'internal:control' && parts[0].body === 'any-frame' ? parts.slice(1) : parts; // Allow nested selectors to start with the same frame selector. - if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), outerParts.slice(0, lastFrameIndex + 1))) + if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), outerParts.slice(0, lastFrameIndex + 1))) { nested.body.parsed.parts.splice(0, lastFrameIndex + 1); + if (nested.body.parsed.capture !== undefined) { + if (nested.body.parsed.capture <= lastFrameIndex) + throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`); + // The capture refers to a part index, so it shifts along with the removed prefix. + nested.body.parsed.capture -= lastFrameIndex + 1; + } + } parts.push(nested); continue; } diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index ff48d2cf8fb1c..9fc227adcddaa 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -251,6 +251,16 @@ it('should allow some, but not all nested frameLocators', async ({ page }) => { expect(error2.message).toContain(`Frame locators are not allowed inside composite locators, while querying "locator('iframe').contentFrame().locator('div').and(locator('#iframe').contentFrame().locator('span'))`); }); +it('should keep the capture when removing a common frame prefix', async ({ page }) => { + await page.setContent(``); + const inner = page.frameLocator('#f').locator('*css=section >> span'); + await expect(page.frameLocator('#f').locator('body').locator(inner)).toHaveAttribute('id', 'target'); + + const captureFrame = page.locator('*css=#f').contentFrame().locator('span'); + const error = await page.frameLocator('#f').locator('body').locator(captureFrame).count().catch(e => e); + expect(error.message).toContain('Can not capture the selector before diving into the frame'); +}); + 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]; From 796831d371854a3d7bc0d789045b7ae2c9ce1574 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 27 Aug 2026 20:12:36 +0100 Subject: [PATCH 4/4] feat(selectors): replace anyFrame() with frameLocator() Calling page.frameLocator() or frame.frameLocator() without a selector now starts the match in any frame of the subtree, replacing anyFrame(). The selector format and the behavior are unchanged - only the API shape. Locator descriptions and codegen render the token as frameLocator() in all languages, which also makes it round-trip through the locator parser, unlike the bare any_frame/AnyFrame property forms. --- docs/src/api/class-frame.md | 74 ++++---- docs/src/api/class-framelocator.md | 52 +++++ docs/src/api/class-page.md | 74 ++++---- packages/isomorphic/locatorGenerators.ts | 8 +- packages/isomorphic/locatorParser.ts | 3 +- packages/isomorphic/selectorParser.ts | 2 +- packages/playwright-client/types/types.d.ts | 99 +++++----- packages/playwright-core/src/client/frame.ts | 8 +- .../playwright-core/src/client/locator.ts | 2 +- packages/playwright-core/src/client/page.ts | 6 +- .../src/server/frameSelectors.ts | 4 +- .../src/server/recorder/recorderUtils.ts | 2 +- packages/playwright-core/types/types.d.ts | 99 +++++----- tests/library/inspector/cli-codegen-3.spec.ts | 54 +++--- tests/library/locator-generator.spec.ts | 26 ++- tests/page/locator-any-frame.spec.ts | 178 +++++++++--------- tests/page/selectors-frame.spec.ts | 2 +- 17 files changed, 376 insertions(+), 317 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index a956e52099973..2d846912c6d2e 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -185,44 +185,6 @@ current working directory. Raw CSS content to be injected into frame. -## method: Frame.anyFrame -* since: v1.63 -- returns: <[FrameLocator]> - -When working with iframes, you can create a frame locator that will start the search in this frame -or in any of the iframes inside it, so that you don't need to locate each iframe first. - -Note that the rest of the locator is resolved inside a single frame, just like any other locator. If it matches elements inside multiple frames, an error is thrown. - -**Usage** - -Following snippet locates a button, either in the frame or in one of the iframes inside it: - -```js -const locator = frame.anyFrame().getByRole('button'); -await locator.click(); -``` - -```java -Locator locator = frame.anyFrame().getByRole(AriaRole.BUTTON); -locator.click(); -``` - -```python async -locator = frame.any_frame.get_by_role("button") -await locator.click() -``` - -```python sync -locator = frame.any_frame.get_by_role("button") -locator.click() -``` - -```csharp -var locator = frame.AnyFrame.GetByRole(AriaRole.Button); -await locator.ClickAsync(); -``` - ## async method: Frame.check * since: v1.8 * discouraged: Use locator-based [`method: Locator.check`] instead. Read more about [locators](../locators.md). @@ -1008,6 +970,10 @@ Console.WriteLine(frame == contentFrame); // -> True When working with iframes, you can create a frame locator that will enter the iframe and allow selecting elements in that iframe. +When called without [`param: selector`], the search starts in this frame or in any of the iframes inside it, +so that you don't need to locate each iframe first. Note that the rest of the locator is resolved inside a single +frame, just like any other locator. If it matches elements inside multiple frames, an error is thrown. + **Usage** Following snippet locates element with text "Submit" in the iframe with id `my-frame`, like `"> `, server.EMPTY_PAGE, 5); - // The bare selector is ambiguous across frames, so anyFrame() cannot pinpoint + // The bare selector is ambiguous across frames, so a bare frameLocator() cannot pinpoint // the target frame and the full frame chain is used instead. const frame = page.mainFrame().childFrames()[0].childFrames()[0]; const [sources] = await Promise.all([ @@ -1144,14 +1144,14 @@ test.describe('cli codegen with anyFrame', () => { expect.soft(clickAction.selector).toBe('#frame1 >> internal:control=enter-frame >> iframe >> internal:control=enter-frame >> internal:role=button[name="Hello"i]'); }); - test('should not use anyFrame when it saves a single frameLocator', async ({ openRecorder, server }) => { + test('should not use frameLocator() when it saves a single frameLocator(selector)', async ({ openRecorder, server }) => { const { page, recorder } = await openRecorder(); await recorder.setContentAndWait(` `, server.EMPTY_PAGE, 2); - // "Hello" is unique across frames, but anyFrame() would only replace a single - // frameLocator() call, so the plain frame chain is not any longer. + // "Hello" is unique across frames, but a bare frameLocator() would only replace a single + // frameLocator(selector) call, so the plain frame chain is not any longer. const frame = page.mainFrame().childFrames()[0]; const [sources] = await Promise.all([ recorder.waitForOutput('JavaScript', 'click'), diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 394291a8c3e3f..595a7d193066c 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -442,20 +442,18 @@ it('reverse engineer frameLocator', async ({ page }) => { expect.soft(asLocator('javascript', selector)).toBe(`locator('div').locator('iframe').contentFrame().locator('span')`); }); -it('reverse engineer anyFrame', async ({ page }) => { - expect.soft(generate(page.anyFrame().getByText('foo').locator('span'))).toEqual({ - csharp: `AnyFrame.GetByText("foo").Locator("span")`, - java: `anyFrame().getByText("foo").locator("span")`, - javascript: `anyFrame().getByText('foo').locator('span')`, - python: `any_frame.get_by_text("foo").locator("span")`, - }); - - // Note that bare `any_frame` and `AnyFrame` are not restored back, - // because they are indistinguishable from a css selector. - expect.soft(asLocator('javascript', 'internal:control=any-frame')).toBe(`anyFrame()`); - expect.soft(asLocator('python', 'internal:control=any-frame')).toBe(`any_frame`); - expect.soft(asLocator('java', 'internal:control=any-frame')).toBe(`anyFrame()`); - expect.soft(asLocator('csharp', 'internal:control=any-frame')).toBe(`AnyFrame`); +it('reverse engineer frameLocator without a selector', async ({ page }) => { + expect.soft(generate(page.frameLocator().getByText('foo').locator('span'))).toEqual({ + csharp: `FrameLocator().GetByText("foo").Locator("span")`, + java: `frameLocator().getByText("foo").locator("span")`, + javascript: `frameLocator().getByText('foo').locator('span')`, + python: `frame_locator().get_by_text("foo").locator("span")`, + }); + + expect.soft(asLocator('javascript', 'internal:control=any-frame')).toBe(`frameLocator()`); + expect.soft(asLocator('python', 'internal:control=any-frame')).toBe(`frame_locator()`); + expect.soft(asLocator('java', 'internal:control=any-frame')).toBe(`frameLocator()`); + expect.soft(asLocator('csharp', 'internal:control=any-frame')).toBe(`FrameLocator()`); }); it('generate multiple locators', async ({ page }) => { diff --git a/tests/page/locator-any-frame.spec.ts b/tests/page/locator-any-frame.spec.ts index 6c64ff3b878ec..9059c437fac3f 100644 --- a/tests/page/locator-any-frame.spec.ts +++ b/tests/page/locator-any-frame.spec.ts @@ -37,7 +37,7 @@ it('should click a button inside an iframe', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); - await page.anyFrame().getByRole('button', { name: 'Click me' }).click(); + await page.frameLocator().getByRole('button', { name: 'Click me' }).click(); expect(await page.frames()[1].evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -45,7 +45,7 @@ it('should click a button in the main frame', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
No buttons here
`); await page.goto(server.EMPTY_PAGE); - await page.anyFrame().locator('button').click(); + await page.frameLocator().locator('button').click(); expect(await page.evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -55,9 +55,9 @@ it('should fail click when elements match in multiple frames', async ({ page, se await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'button'); - const error = await page.anyFrame().locator('button').click({ timeout: 3000 }).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); - expect(error.message).toContain(`waiting for anyFrame().locator('button')`); + const error = await page.frameLocator().locator('button').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); + expect(error.message).toContain(`waiting for frameLocator().locator('button')`); }); it('should fail click upon strict mode violation inside a single frame', async ({ page, server }) => { @@ -65,18 +65,18 @@ it('should fail click upon strict mode violation inside a single frame', async ( await routePage(page, 'a.html', ``); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'button'); - const error = await page.anyFrame().locator('button').click({ timeout: 3000 }).catch(e => e); + const error = await page.frameLocator().locator('button').click({ timeout: 3000 }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain(`waiting for anyFrame().locator('button')`); + expect(error.message).toContain(`waiting for frameLocator().locator('button')`); }); it('should time out on click when there are no matches', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Nothing here
`); await page.goto(server.EMPTY_PAGE); - const error = await page.anyFrame().locator('button').click({ timeout: 1000 }).catch(e => e); + const error = await page.frameLocator().locator('button').click({ timeout: 1000 }).catch(e => e); expect(error.message).toContain('Timeout 1000ms exceeded'); - expect(error.message).toContain(`waiting for anyFrame().locator('button')`); + expect(error.message).toContain(`waiting for frameLocator().locator('button')`); }); it('should count elements in a single frame', async ({ page, server }) => { @@ -84,8 +84,8 @@ it('should count elements in a single frame', async ({ page, server }) => { await routePage(page, 'a.html', `
1
2
3
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'div'); - expect(await page.anyFrame().locator('div').count()).toBe(3); - expect(await page.anyFrame().locator('button').count()).toBe(0); + expect(await page.frameLocator().locator('div').count()).toBe(3); + expect(await page.frameLocator().locator('button').count()).toBe(0); }); it('should fail count when elements match in multiple frames', async ({ page, server }) => { @@ -93,16 +93,16 @@ it('should fail count when elements match in multiple frames', async ({ page, se await routePage(page, 'a.html', `
child
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'div'); - const error = await page.anyFrame().locator('div').count().catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + const error = await page.frameLocator().locator('div').count().catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); }); it('should support toHaveCount', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `onetwo`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('span')).toHaveCount(2); - await expect(page.anyFrame().locator('button')).toHaveCount(0); + await expect(page.frameLocator().locator('span')).toHaveCount(2); + await expect(page.frameLocator().locator('button')).toHaveCount(0); }); it('should wait for a frame to appear with toHaveCount', async ({ page, server }) => { @@ -116,7 +116,7 @@ it('should wait for a frame to appear with toHaveCount', async ({ page, server } document.body.appendChild(iframe); }, 500); }); - await expect(page.anyFrame().locator('span')).toHaveCount(2); + await expect(page.frameLocator().locator('span')).toHaveCount(2); }); it('should fail toHaveCount when elements match in multiple frames', async ({ page, server }) => { @@ -125,23 +125,23 @@ it('should fail toHaveCount when elements match in multiple frames', async ({ pa await routePage(page, 'b.html', `two`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'span'); - const error = await expect(page.anyFrame().locator('span')).toHaveCount(2, { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); - expect(error.message).toContain(`Locator: anyFrame().locator('span')`); + const error = await expect(page.frameLocator().locator('span')).toHaveCount(2, { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); + expect(error.message).toContain(`Locator: frameLocator().locator('span')`); }); it('should support toHaveText', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Hello iframe
`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('div')).toHaveText('Hello iframe'); + await expect(page.frameLocator().locator('div')).toHaveText('Hello iframe'); }); it('should support toHaveText with an array', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `onetwo`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('span')).toHaveText(['one', 'two']); + await expect(page.frameLocator().locator('span')).toHaveText(['one', 'two']); }); it('should fail toHaveText when elements match in multiple frames', async ({ page, server }) => { @@ -150,9 +150,9 @@ it('should fail toHaveText when elements match in multiple frames', async ({ pag await routePage(page, 'b.html', `
two
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'div'); - const error = await expect(page.anyFrame().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); - expect(error.message).toContain(`Locator: anyFrame().locator('div')`); + const error = await expect(page.frameLocator().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); + expect(error.message).toContain(`Locator: frameLocator().locator('div')`); }); it('should fail toHaveText with an array when elements match in multiple frames', async ({ page, server }) => { @@ -161,8 +161,8 @@ it('should fail toHaveText with an array when elements match in multiple frames' await routePage(page, 'b.html', `two`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'span'); - const error = await expect(page.anyFrame().locator('span')).toHaveText(['one', 'two'], { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + const error = await expect(page.frameLocator().locator('span')).toHaveText(['one', 'two'], { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); }); it('should fail toHaveText upon strict mode violation inside a single frame', async ({ page, server }) => { @@ -170,16 +170,16 @@ it('should fail toHaveText upon strict mode violation inside a single frame', as await routePage(page, 'a.html', `
one
two
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'div'); - const error = await expect(page.anyFrame().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); + const error = await expect(page.frameLocator().locator('div')).toHaveText('one', { timeout: 3000 }).catch(e => e); expect(error.message).toContain('strict mode violation'); - expect(error.message).toContain(`Locator: anyFrame().locator('div')`); + expect(error.message).toContain(`Locator: frameLocator().locator('div')`); }); it('should support evaluate', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Hello
`); await page.goto(server.EMPTY_PAGE); - expect(await page.anyFrame().locator('div').evaluate(e => e.getAttribute('data-foo'))).toBe('bar'); + expect(await page.frameLocator().locator('div').evaluate(e => e.getAttribute('data-foo'))).toBe('bar'); }); it('should fail evaluate when elements match in multiple frames', async ({ page, server }) => { @@ -188,17 +188,17 @@ it('should fail evaluate when elements match in multiple frames', async ({ page, await routePage(page, 'b.html', `
two
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'div'); - const error = await page.anyFrame().locator('div').evaluate(e => e.textContent, undefined, { timeout: 3000 }).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + const error = await page.frameLocator().locator('div').evaluate(e => e.textContent, undefined, { timeout: 3000 }).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); }); it('should time out on evaluate when there are no matches', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
Nothing here
`); await page.goto(server.EMPTY_PAGE); - const error = await page.anyFrame().locator('button').evaluate(e => e.textContent, undefined, { timeout: 1000 }).catch(e => e); + const error = await page.frameLocator().locator('button').evaluate(e => e.textContent, undefined, { timeout: 1000 }).catch(e => e); expect(error.message).toContain('Timeout 1000ms exceeded'); - expect(error.message).toContain(`waiting for anyFrame().locator('button')`); + expect(error.message).toContain(`waiting for frameLocator().locator('button')`); }); it('should support evaluateAll', async ({ page, server }) => { @@ -206,8 +206,8 @@ it('should support evaluateAll', async ({ page, server }) => { await routePage(page, 'a.html', `onetwo`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'span'); - expect(await page.anyFrame().locator('span').evaluateAll(els => els.map(e => e.textContent))).toEqual(['one', 'two']); - expect(await page.anyFrame().locator('button').evaluateAll(els => els.length)).toBe(0); + expect(await page.frameLocator().locator('span').evaluateAll(els => els.map(e => e.textContent))).toEqual(['one', 'two']); + expect(await page.frameLocator().locator('button').evaluateAll(els => els.length)).toBe(0); }); it('should fail evaluateAll when elements match in multiple frames', async ({ page, server }) => { @@ -216,15 +216,15 @@ it('should fail evaluateAll when elements match in multiple frames', async ({ pa await routePage(page, 'b.html', `two`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 3, 'span'); - const error = await page.anyFrame().locator('span').evaluateAll(els => els.length).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + const error = await page.frameLocator().locator('span').evaluateAll(els => els.length).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); }); it('should support hasText filter', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
foo
bar
`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('div', { hasText: 'bar' })).toHaveText('bar'); + await expect(page.frameLocator().locator('div', { hasText: 'bar' })).toHaveText('bar'); }); it('should support first/last/nth', async ({ page, server }) => { @@ -232,30 +232,30 @@ it('should support first/last/nth', async ({ page, server }) => { await routePage(page, 'a.html', `onetwothree`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'span'); - await expect(page.anyFrame().locator('span').first()).toHaveText('one'); - await expect(page.anyFrame().locator('span').last()).toHaveText('three'); - await expect(page.anyFrame().locator('span').nth(1)).toHaveText('two'); + await expect(page.frameLocator().locator('span').first()).toHaveText('one'); + await expect(page.frameLocator().locator('span').last()).toHaveText('three'); + await expect(page.frameLocator().locator('span').nth(1)).toHaveText('two'); }); it('should support nth in the middle of the chain', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
one
two
`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('div').nth(1).locator('span')).toHaveText('two'); + await expect(page.frameLocator().locator('div').nth(1).locator('span')).toHaveText('two'); }); it('should support composite locators', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
foo
bar
`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('div', { has: page.locator('span') })).toHaveText('foo'); + await expect(page.frameLocator().locator('div', { has: page.locator('span') })).toHaveText('foo'); }); it('should support capture', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
hello
`); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('*css=div >> span')).toHaveAttribute('id', 'target'); + await expect(page.frameLocator().locator('*css=div >> span')).toHaveAttribute('id', 'target'); }); it('should find a frame inside the scope', async ({ page, server }) => { @@ -318,8 +318,8 @@ it('should not match a chain across a frame boundary', async ({ page, server }) const deepFrame = page.frames().find(f => f.url().includes('b.html'))!; await deepFrame.waitForSelector('button', { state: 'attached' }); // "section" lives in the main frame, while the button is two frames below it. - await expect(page.anyFrame().locator('section').locator('button')).toHaveCount(0); - await expect(page.anyFrame().locator('button')).toHaveText('deep'); + await expect(page.frameLocator().locator('section').locator('button')).toHaveCount(0); + await expect(page.frameLocator().locator('button')).toHaveText('deep'); }); it('should only search frames inside the starting frame', async ({ page, server }) => { @@ -331,7 +331,7 @@ it('should only search frames inside the starting frame', async ({ page, server const deepFrame = page.frames().find(f => f.url().includes('b.html'))!; await deepFrame.waitForSelector('button', { state: 'attached' }); const middleFrame = page.frames().find(f => f.url().includes('a.html'))!; - await expect(middleFrame.anyFrame().locator('button')).toHaveText('deep'); + await expect(middleFrame.frameLocator().locator('button')).toHaveText('deep'); }); it('should enter a frame found in a nested frame', async ({ page, server }) => { @@ -339,7 +339,7 @@ it('should enter a frame found in a nested frame', async ({ page, server }) => { await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().frameLocator('#target').locator('button')).toHaveText('inside'); + await expect(page.frameLocator().frameLocator('#target').locator('button')).toHaveText('inside'); }); it('should click inside an entered frame', async ({ page, server }) => { @@ -347,7 +347,7 @@ it('should click inside an entered frame', async ({ page, server }) => { await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await page.anyFrame().frameLocator('#target').getByRole('button', { name: 'Click me' }).click(); + await page.frameLocator().frameLocator('#target').getByRole('button', { name: 'Click me' }).click(); const frame = page.frames().find(f => f.url().includes('b.html'))!; expect(await frame.evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -359,7 +359,7 @@ it('should not search nested frames after entering a frame', async ({ page, serv await page.goto(server.EMPTY_PAGE); await expect.poll(() => page.frames().length).toBe(3); // The entered frame itself has no button, and we do not look inside its nested frames. - await expect(page.anyFrame().frameLocator('#target').locator('button')).toHaveCount(0); + await expect(page.frameLocator().frameLocator('#target').locator('button')).toHaveCount(0); }); it('should support two frameLocators', async ({ page, server }) => { @@ -368,7 +368,7 @@ it('should support two frameLocators', async ({ page, server }) => { await routePage(page, 'b.html', ``); await routePage(page, 'c.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().frameLocator('#x').frameLocator('#y').locator('button')).toHaveText('bottom'); + await expect(page.frameLocator().frameLocator('#x').frameLocator('#y').locator('button')).toHaveText('bottom'); }); it('should support locator before frameLocator', async ({ page, server }) => { @@ -377,7 +377,7 @@ it('should support locator before frameLocator', async ({ page, server }) => { await routePage(page, 'b.html', ``); await routePage(page, 'c.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('section').frameLocator('iframe').locator('button')).toHaveText('in-section'); + await expect(page.frameLocator().locator('section').frameLocator('iframe').locator('button')).toHaveText('in-section'); }); it('should support owner of a frameLocator', async ({ page, server }) => { @@ -385,7 +385,7 @@ it('should support owner of a frameLocator', async ({ page, server }) => { await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - expect(await page.anyFrame().frameLocator('#target').owner().getAttribute('id')).toBe('target'); + expect(await page.frameLocator().frameLocator('#target').owner().getAttribute('id')).toBe('target'); }); it('should wait for the frame to enter to appear', async ({ page, server }) => { @@ -402,7 +402,7 @@ it('should wait for the frame to enter to appear', async ({ page, server }) => { document.body.appendChild(iframe); }, 3000); }); - await expect(page.anyFrame().frameLocator('#late').locator('button')).toHaveText('late'); + await expect(page.frameLocator().frameLocator('#late').locator('button')).toHaveText('late'); }); it('should fail when the frame to enter matches in multiple frames', async ({ page, server }) => { @@ -416,8 +416,8 @@ it('should fail when the frame to enter matches in multiple frames', async ({ pa if (frame.url().includes('c.html')) await frame.waitForSelector('button', { state: 'attached' }); } - const error = await page.anyFrame().frameLocator('.inner').locator('button').click({ timeout: 3000 }).catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + const error = await page.frameLocator().frameLocator('.inner').locator('button').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); }); it('should support contentFrame', async ({ page, server }) => { @@ -425,65 +425,65 @@ it('should support contentFrame', async ({ page, server }) => { await routePage(page, 'a.html', ``); await routePage(page, 'b.html', ``); await page.goto(server.EMPTY_PAGE); - await expect(page.anyFrame().locator('#target').contentFrame().locator('button')).toHaveText('inside'); + await expect(page.frameLocator().locator('#target').contentFrame().locator('button')).toHaveText('inside'); }); -it('should render anyFrame in the locator description', async ({ page }) => { - expect(String(page.anyFrame().frameLocator('#x').locator('button'))).toBe(`anyFrame().locator('#x').contentFrame().locator('button')`); - expect(String(page.anyFrame().locator('section').frameLocator('iframe').getByText('foo'))).toBe(`anyFrame().locator('section').locator('iframe').contentFrame().getByText('foo')`); +it('should render frameLocator() in the locator description', async ({ page }) => { + expect(String(page.frameLocator().frameLocator('#x').locator('button'))).toBe(`frameLocator().locator('#x').contentFrame().locator('button')`); + expect(String(page.frameLocator().locator('section').frameLocator('iframe').getByText('foo'))).toBe(`frameLocator().locator('section').locator('iframe').contentFrame().getByText('foo')`); }); -it('should not allow anyFrame inside a composite locator', async ({ page, server }) => { +it('should not allow frameLocator() inside a composite locator', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `link`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'a'); - const error = await page.locator('button').or(page.anyFrame().locator('a')).count().catch(e => e); - expect(error.message).toContain(`anyFrame() is not allowed inside composite locators, while querying "locator('button').or(anyFrame().locator('a'))"`); + const error = await page.locator('button').or(page.frameLocator().locator('a')).count().catch(e => e); + expect(error.message).toContain(`frameLocator() is not allowed inside composite locators, while querying "locator('button').or(frameLocator().locator('a'))"`); - const error2 = await page.locator('button').filter({ has: page.anyFrame().locator('a') }).count().catch(e => e); - expect(error2.message).toContain(`anyFrame() is not allowed inside composite locators`); + const error2 = await page.locator('button').filter({ has: page.frameLocator().locator('a') }).count().catch(e => e); + expect(error2.message).toContain(`frameLocator() is not allowed inside composite locators`); - // Repeating anyFrame() in the operand is not allowed either, even though the outer locator has it. - const error3 = await page.anyFrame().locator('button').or(page.anyFrame().locator('a')).count().catch(e => e); - expect(error3.message).toContain(`anyFrame() is not allowed inside composite locators, while querying "anyFrame().locator('button').or(anyFrame().locator('a'))"`); + // Repeating frameLocator() in the operand is not allowed either, even though the outer locator has it. + const error3 = await page.frameLocator().locator('button').or(page.frameLocator().locator('a')).count().catch(e => e); + expect(error3.message).toContain(`frameLocator() is not allowed inside composite locators, while querying "frameLocator().locator('button').or(frameLocator().locator('a'))"`); - // Repeating anyFrame() is not allowed even when the rest of the frame chain matches. - const error4 = await page.anyFrame().frameLocator('#f').locator('a').or(page.anyFrame().frameLocator('#f').locator('button')).count().catch(e => e); - expect(error4.message).toContain(`anyFrame() is not allowed inside composite locators`); + // Repeating frameLocator() is not allowed even when the rest of the frame chain matches. + const error4 = await page.frameLocator().frameLocator('#f').locator('a').or(page.frameLocator().frameLocator('#f').locator('button')).count().catch(e => e); + expect(error4.message).toContain(`frameLocator() is not allowed inside composite locators`); - // With anyFrame() first, the token applies to the whole locator, so both operands are searched in every frame. - const error5 = await page.anyFrame().locator('a').or(page.locator('button')).count().catch(e => e); - expect(error5.message).toContain(`anyFrame() matched elements in multiple frames`); + // With frameLocator() first, the token applies to the whole locator, so both operands are searched in every frame. + const error5 = await page.frameLocator().locator('a').or(page.locator('button')).count().catch(e => e); + expect(error5.message).toContain(`frameLocator() matched elements in multiple frames`); }); -it('should support a composite locator under anyFrame', async ({ page, server }) => { +it('should support a composite locator under frameLocator()', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', `
first
`); await page.goto(server.EMPTY_PAGE); await waitForAllFrames(page, 2, 'button'); - await expect(page.anyFrame().locator('.classname').or(page.getByRole('button'))).toHaveText(['first', 'second']); + await expect(page.frameLocator().locator('.classname').or(page.getByRole('button'))).toHaveText(['first', 'second']); }); -it('should support a composite locator under anyFrame and a frame locator', async ({ page, server }) => { +it('should support a composite locator under frameLocator() and a frame locator', async ({ page, server }) => { await routePage(page, 'empty.html', ``); await routePage(page, 'a.html', ``); await routePage(page, 'b.html', `
first
`); await page.goto(server.EMPTY_PAGE); await expect.poll(() => page.frames().length).toBe(3); - await expect(page.anyFrame().frameLocator('#f').locator('.classname').or(page.frameLocator('#f').getByRole('button'))).toHaveText(['first', 'second']); + await expect(page.frameLocator().frameLocator('#f').locator('.classname').or(page.frameLocator('#f').getByRole('button'))).toHaveText(['first', 'second']); }); -it('should not allow first/last/nth on anyFrame', async ({ page }) => { - expect(() => page.anyFrame().first()).toThrow('Selecting the nth frame is not allowed on anyFrame()'); - expect(() => page.anyFrame().last()).toThrow('Selecting the nth frame is not allowed on anyFrame()'); - expect(() => page.anyFrame().nth(1)).toThrow('Selecting the nth frame is not allowed on anyFrame()'); +it('should not allow first/last/nth on frameLocator()', async ({ page }) => { + expect(() => page.frameLocator().first()).toThrow('Selecting the nth frame is not allowed on frameLocator()'); + expect(() => page.frameLocator().last()).toThrow('Selecting the nth frame is not allowed on frameLocator()'); + expect(() => page.frameLocator().nth(1)).toThrow('Selecting the nth frame is not allowed on frameLocator()'); }); -it('should not allow owner on anyFrame', async ({ page }) => { - const error = await page.anyFrame().owner().count().catch(e => e); - expect(error.message).toContain('Selector cannot be empty after anyFrame()'); +it('should not allow owner on frameLocator()', async ({ page }) => { + const error = await page.frameLocator().owner().count().catch(e => e); + expect(error.message).toContain('Selector cannot be empty after frameLocator()'); }); it('should resolve aria-ref selectors', async ({ page, server }) => { @@ -495,7 +495,7 @@ it('should resolve aria-ref selectors', async ({ page, server }) => { // Refs are unique across frames, so every starting frame resolves them to the same element. const insideMatch = snapshot.match(/button "inside" \[ref=(.*?)\]/); expect(insideMatch![1]).toMatch(/^f\d+e\d+$/); - await expect(page.anyFrame().locator(`aria-ref=${insideMatch![1]}`)).toHaveText('inside'); + await expect(page.frameLocator().locator(`aria-ref=${insideMatch![1]}`)).toHaveText('inside'); }); it('should click while another iframe is stalled', async ({ page, server }) => { @@ -504,7 +504,7 @@ it('should click while another iframe is stalled', async ({ page, server }) => { await page.route('**/stall.html', () => {}); await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); await expect.poll(() => page.frames().length).toBe(3); - await page.anyFrame().locator('button').click(); + await page.frameLocator().locator('button').click(); const frame = page.frames().find(f => f.url().includes('a.html'))!; expect(await frame.evaluate(() => (window as any).__clicked)).toBe(true); }); @@ -515,7 +515,7 @@ it('should support toBeVisible while another iframe is stalled', async ({ page, await page.route('**/stall.html', () => {}); await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); await expect.poll(() => page.frames().length).toBe(3); - await expect(page.anyFrame().locator('button')).toBeVisible(); + await expect(page.frameLocator().locator('button')).toBeVisible(); }); it('should support toHaveCount while another iframe is stalled', async ({ page, server }) => { @@ -524,5 +524,5 @@ it('should support toHaveCount while another iframe is stalled', async ({ page, await page.route('**/stall.html', () => {}); await page.goto(server.EMPTY_PAGE, { waitUntil: 'domcontentloaded' }); await expect.poll(() => page.frames().length).toBe(3); - await expect(page.anyFrame().locator('button')).toHaveCount(1); + await expect(page.frameLocator().locator('button')).toHaveCount(1); }); diff --git a/tests/page/selectors-frame.spec.ts b/tests/page/selectors-frame.spec.ts index 4ffd0e7cb09c0..62d5899a9fa6c 100644 --- a/tests/page/selectors-frame.spec.ts +++ b/tests/page/selectors-frame.spec.ts @@ -376,7 +376,7 @@ it('should throw when matching elements in multiple frames', async ({ page, serv } const error = await page.locator('internal:control=any-frame >> div').innerHTML().catch(e => e); - expect(error.message).toContain('anyFrame() matched elements in multiple frames'); + expect(error.message).toContain('frameLocator() matched elements in multiple frames'); }); it('should not allow any-frame in the middle of a selector', async ({ page, server }) => {