From 2882ceff5a3524e6ddf9d16e002ce76ab7e8a28d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 28 Jul 2026 12:00:02 +0100 Subject: [PATCH] fix(selectors): respect scope when piercing frames - respect the query scope (e.g. elementHandle.$$) with pierce-frames selectors - pierce nested frames when matching a selector suffix after a matched prefix - frame.pierceFrames() now searches only inside the frame subtree --- docs/src/api/class-frame.md | 8 +-- packages/playwright-client/types/types.d.ts | 8 +-- .../src/server/frameSelectors.ts | 26 ++++---- packages/playwright-core/types/types.d.ts | 8 +-- tests/page/locator-pierce-frames.spec.ts | 62 +++++++++++++++++++ 5 files changed, 87 insertions(+), 25 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 5295065f84a76..50173bbb8b27a 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -1443,16 +1443,16 @@ Parent frame, if any. Detached frames and main frames return `null`. * 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. +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 page contains +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 main frame or in one of the iframes: +Following snippet locates a button, either in the frame or in one of the iframes inside it: ```js const locator = frame.pierceFrames().getByRole('button'); diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index baa5f1d759c4a..05f08a7a45b94 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -8166,16 +8166,16 @@ export interface Frame { parentFrame(): null|Frame; /** - * 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. + * 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 page contains two + * 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 main frame or in one of the iframes: + * Following snippet locates a button, either in the frame or in one of the iframes inside it: * * ```js * const locator = frame.pierceFrames().getByRole('button'); diff --git a/packages/playwright-core/src/server/frameSelectors.ts b/packages/playwright-core/src/server/frameSelectors.ts index 27f6d7b554e6b..7233ac85a7422 100644 --- a/packages/playwright-core/src/server/frameSelectors.ts +++ b/packages/playwright-core/src/server/frameSelectors.ts @@ -181,18 +181,17 @@ export class FrameSelectors { private async _resolveFramePiercingSelector(parsed: ParsedSelector, options: types.StrictOptions, scope: ElementHandle | undefined) { const candidates = new Map>(); const infos = parsed.parts.map(part => this._parseSelector({ parts: [part] }, options)); - for (const frame of this.frame._page.frameManager.frames()) - await this._pierceFramesRecursivelyIfNotSeen(frame, infos, scope, 0, candidates); + await this._pierceFramesRecursivelyIfNotSeen(this.frame, infos, scope, 0, candidates); const result: SelectorInFrame[] = []; - for (const [frame, matches] of candidates) { - for (const match of matches) { - const suffix = infos.slice(match); + for (const [frame, startIndexes] of candidates) { + for (const startIndex of startIndexes) { + const suffix = infos.slice(startIndex); const partialInfo: SelectorInfo = { parsed: { parts: suffix.map(info => info.parsed.parts[0]) }, world: suffix.some(info => info.world === 'main') ? 'main' : 'utility', strict: !!options.strict, }; - result.push({ frame, info: partialInfo }); + result.push({ frame, info: partialInfo, scope: frame === this.frame && startIndex === 0 ? scope : undefined }); } } return result; @@ -206,7 +205,7 @@ export class FrameSelectors { } if (!set.has(startIndex)) { set.add(startIndex); - await this._pierceFramesRecursively(frame, infos, undefined, startIndex, result); + await this._pierceFramesRecursively(frame, infos, scope, startIndex, result); } } @@ -215,7 +214,8 @@ export class FrameSelectors { const injected = await context.injectedScript(); const frameCandidatesHandle = await injected.evaluateHandle((injected, { infos, scope, startIndex }) => { const frameElements = injected.querySelectorAll(injected.parseSelector('css=frame,iframe'), scope || document); - const result = frameElements.map(frameElement => ({ frameElement, matches: [] as number[] })); + // Any frame inside the search root may contain the whole selector suffix, so seed with startIndex. + const result = frameElements.map(frameElement => ({ frameElement, nextIndexes: [startIndex] })); let roots = [scope || document]; for (let index = startIndex; index < infos.length; index++) { @@ -227,9 +227,9 @@ export class FrameSelectors { } roots = [...next]; if (index + 1 < infos.length && !['nth', 'visible'].includes(infos[index + 1].parsed.parts[0].name)) { - for (const { frameElement, matches } of result) { + for (const { frameElement, nextIndexes } of result) { if (roots.some(root => injected.utils.isInsideScope(root, frameElement))) - matches.push(index); + nextIndexes.push(index + 1); } } } @@ -242,9 +242,9 @@ export class FrameSelectors { const frameElement = await frameCandidatesHandle.evaluateHandle((list, i) => list[i].frameElement, i) as ElementHandle; const childFrame = await frame._page.delegate.getContentFrame(frameElement).catch(() => null); if (childFrame) { - const matches = await frameCandidatesHandle.evaluate((list, i) => list[i].matches, i) as number[]; - for (const match of matches) - await this._pierceFramesRecursivelyIfNotSeen(childFrame, infos, undefined, match + 1, result); + const nextIndexes = await frameCandidatesHandle.evaluate((list, i) => list[i].nextIndexes, i) as number[]; + for (const nextIndex of nextIndexes) + await this._pierceFramesRecursivelyIfNotSeen(childFrame, infos, undefined, nextIndex, result); } } catch { // Ignore errors for this frame candidate. diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index baa5f1d759c4a..05f08a7a45b94 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8166,16 +8166,16 @@ export interface Frame { parentFrame(): null|Frame; /** - * 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. + * 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 page contains two + * 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 main frame or in one of the iframes: + * Following snippet locates a button, either in the frame or in one of the iframes inside it: * * ```js * const locator = frame.pierceFrames().getByRole('button'); diff --git a/tests/page/locator-pierce-frames.spec.ts b/tests/page/locator-pierce-frames.spec.ts index 08dcba04932e3..5ad3bd4a5be8a 100644 --- a/tests/page/locator-pierce-frames.spec.ts +++ b/tests/page/locator-pierce-frames.spec.ts @@ -237,6 +237,68 @@ it('should support first/last/nth as the last operation', async ({ page, server await expect(page.pierceFrames().locator('span').nth(1)).toHaveText('two'); }); +it('should pierce 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'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('inside'); +}); + +it('should pierce a nested frame inside the scope', 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.poll(() => page.frames().length).toBe(4); + for (const frame of page.frames()) { + if (frame.url().includes('b.html') || frame.url().includes('c.html')) + await frame.waitForSelector('button', { state: 'attached' }); + } + const scope = (await page.$('section'))!; + const buttons = await scope.$$('internal:control=pierce-frames >> button'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('deep'); +}); + +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'); + expect(buttons.length).toBe(1); + expect(await buttons[0].textContent()).toBe('target'); +}); + +it('should pierce nested frames below a matching prefix', 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.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'); +}); + +it('should pierce only frames inside the starting 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.poll(() => page.frames().length).toBe(3); + 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'); +}); + 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')"`);