From f4d3db60cc6da1d6e9350e2b9f9596f942fa52d1 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Tue, 22 Sep 2026 13:08:27 +0600 Subject: [PATCH] fix(highlight): resolve highlights in the frame of the locator Fixes https://github.com/microsoft/playwright/issues/42850 --- .../src/server/dispatchers/frameDispatcher.ts | 4 +- .../src/server/highlightController.ts | 15 +++- tests/library/locator-highlight.spec.ts | 88 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) 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(); +});