diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts
index 7733df73fb44e..7d37f78bda32d 100644
--- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts
+++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts
@@ -272,11 +272,11 @@ export class FrameDispatcher extends Dispatcher {
- return await progress.race(this._frame._page.highlightController.addHighlight(params.selector, { style: params.style }));
+ return await progress.race(this._frame._page.highlightController.addHighlight(params.selector, { style: params.style, frame: this._frame }));
}
async hideHighlight(params: channels.FrameHideHighlightParams, progress: Progress): Promise {
- return await progress.race(this._frame._page.highlightController.removeHighlight(params.selector));
+ return await progress.race(this._frame._page.highlightController.removeHighlight(params.selector, this._frame));
}
async expect(params: channels.FrameExpectParams, progress: Progress): Promise {
diff --git a/packages/playwright-core/src/server/highlightController.ts b/packages/playwright-core/src/server/highlightController.ts
index 966d2f167f867..0b23d6df22446 100644
--- a/packages/playwright-core/src/server/highlightController.ts
+++ b/packages/playwright-core/src/server/highlightController.ts
@@ -21,6 +21,7 @@ import type { ParsedSelector } from '@isomorphic/selectorParser';
export type HighlightOptions = {
style?: string;
anyFrame?: boolean; // Highlight in all the frames the selector could resolve to, instead of a single one.
+ frame?: Frame; // Resolve the selector relative to this frame, defaults to the main frame.
};
type HighlightEntry = HighlightOptions & {
@@ -40,15 +41,20 @@ export class HighlightController {
async addHighlight(selector: string, options: HighlightOptions = {}) {
// Validate the selector upfront, so that the caller gets a synchronous error.
this._page.browserContext.selectors().parseSelector(selector, false);
- this._entries.set(selector, { selector, ...options });
+ this._entries.set(this._key(options.frame, selector), { selector, ...options });
await this._resolveNow();
}
- async removeHighlight(selector: string) {
- this._entries.delete(selector);
+ async removeHighlight(selector: string, frame?: Frame) {
+ this._entries.delete(this._key(frame, selector));
await this._resolveNow();
}
+ // The same selector may be highlighted from different frames, so key the entries by frame as well.
+ private _key(frame: Frame | undefined, selector: string): string {
+ return `${(frame ?? this._page.mainFrame()).guid}|${selector}`;
+ }
+
dispose() {
if (this._resolutionTimer) {
clearTimeout(this._resolutionTimer);
@@ -80,7 +86,8 @@ export class HighlightController {
const perFrame = new Map();
for (const entry of this._entries.values()) {
- const results = await this._page.mainFrame().selectors.resolveFramesForSelector(entry.selector, { strict: false, anyFrame: entry.anyFrame }).catch(() => []);
+ const owner = entry.frame ?? this._page.mainFrame();
+ const results = await owner.selectors.resolveFramesForSelector(entry.selector, { strict: false, anyFrame: entry.anyFrame }).catch(() => []);
for (const { frame, info } of results) {
let list = perFrame.get(frame);
if (!list) {
diff --git a/tests/library/locator-highlight.spec.ts b/tests/library/locator-highlight.spec.ts
index 7ed5ca94e07b6..35be326fa2ecb 100644
--- a/tests/library/locator-highlight.spec.ts
+++ b/tests/library/locator-highlight.spec.ts
@@ -118,3 +118,91 @@ test('Page.hideHighlight clears all locator highlights', async ({ browser, serve
await context.close();
});
+
+test('highlight should resolve relative to the frame of the locator', async ({ browser, server }) => {
+ server.setRoute('/highlight/child.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+ server.setRoute('/highlight/parent.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto(server.PREFIX + '/highlight/parent.html');
+ const frame = page.frame({ name: 'frame' })!;
+
+ await frame.locator('button').highlight();
+ await expect(frame.locator('x-pw-highlight')).toHaveCount(1);
+ await expect(page.locator('x-pw-highlight')).toHaveCount(0);
+
+ await frame.locator('button').hideHighlight();
+ await expect(frame.locator('x-pw-highlight')).toHaveCount(0);
+
+ await context.close();
+});
+
+test('highlight same selector in different frames', async ({ browser, server }) => {
+ server.setRoute('/highlight/child.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+ server.setRoute('/highlight/parent.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end([
+ '',
+ '',
+ '',
+ ].join(''));
+ });
+
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto(server.PREFIX + '/highlight/parent.html');
+ const frame1 = page.frame({ name: 'frame1' })!;
+ const frame2 = page.frame({ name: 'frame2' })!;
+
+ await page.locator('button').highlight();
+ await frame1.locator('button').highlight();
+ await frame2.locator('button').highlight();
+ await expect(page.locator('x-pw-highlight')).toHaveCount(1);
+ await expect(frame1.locator('x-pw-highlight')).toHaveCount(1);
+ await expect(frame2.locator('x-pw-highlight')).toHaveCount(1);
+
+ await page.hideHighlight();
+ await expect(page.locator('x-pw-highlight')).toHaveCount(0);
+ await expect(frame1.locator('x-pw-highlight')).toHaveCount(0);
+ await expect(frame2.locator('x-pw-highlight')).toHaveCount(0);
+
+ await context.close();
+});
+
+test('frame highlight should survive frame navigation', async ({ browser, server }) => {
+ server.setRoute('/highlight/child.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+ server.setRoute('/highlight/child2.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+ server.setRoute('/highlight/parent.html', (req, res) => {
+ res.setHeader('Content-Type', 'text/html');
+ res.end('');
+ });
+
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ await page.goto(server.PREFIX + '/highlight/parent.html');
+ const frame = page.frame({ name: 'frame' })!;
+
+ await frame.locator('button').highlight();
+ await expect(frame.locator('x-pw-highlight')).toHaveCount(1);
+
+ await frame.goto(server.PREFIX + '/highlight/child2.html');
+ await expect(frame.locator('x-pw-highlight')).toHaveCount(1);
+
+ await context.close();
+});