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')"`);