diff --git a/packages/browser/src/client/tester/trace.ts b/packages/browser/src/client/tester/trace.ts index 89b20908f..b3acd9073 100644 --- a/packages/browser/src/client/tester/trace.ts +++ b/packages/browser/src/client/tester/trace.ts @@ -48,7 +48,7 @@ interface TraceSnapshot { x: number y: number } - selectorId?: number + selectorIds?: number[] // not used yet for UI but tested selectorResolution?: BrowserTraceSelectorResolution selectorError?: string @@ -154,21 +154,16 @@ function takeSnapshot(serializedLocator?: SerializedLocator): TraceSnapshot { } if (serializedLocator) { try { - const el = engine.querySelector( + const elements = engine.querySelectorAll( engine.parseSelector(serializedLocator._pwSelector ?? serializedLocator.selector), document.documentElement, - false, ) - if (!el) { - result.selectorResolution = 'missing' + const ids = elements.map((el) => mirror.getId(el)).filter((id) => id !== -1) + if (ids.length > 0) { + result.selectorIds = ids + result.selectorResolution = 'matched' } else { - const id = mirror.getId(el) - if (id !== -1) { - result.selectorId = id - result.selectorResolution = 'matched' - } else { - result.selectorResolution = 'missing' - } + result.selectorResolution = 'missing' } } catch (error) { result.selectorResolution = 'error' diff --git a/packages/ui/client/components/trace/TraceView.vue b/packages/ui/client/components/trace/TraceView.vue index 059ce7b27..cb51880bb 100644 --- a/packages/ui/client/components/trace/TraceView.vue +++ b/packages/ui/client/components/trace/TraceView.vue @@ -70,7 +70,7 @@ watch( if (!step || !iframe) { return } - const { serialized, selectorId, viewport, scroll, pseudoClassIds } = step.snapshot + const { serialized, selectorIds, viewport, scroll, pseudoClassIds } = step.snapshot iframe.style.width = `${viewport.width}px` iframe.style.height = `${viewport.height}px` // Rebuild snapshot into iframe contentDocument — pattern from rrweb replayer: @@ -104,15 +104,17 @@ watch( } } iframe.contentWindow!.scrollTo(scroll?.x ?? 0, scroll?.y ?? 0) - if (selectorId != null) { - const el = mirror.getNode(selectorId) - if (el) { - // Overlay highlight technique adapted from Playwright's highlight.ts: - // https://github.com/microsoft/playwright/blob/main/packages/injected/src/highlight.ts - // getBoundingClientRect() gives viewport-relative coords; position:fixed overlay matches. - // Simplified version: no shadow DOM glass pane, no tooltip. - iframe.contentWindow!.requestAnimationFrame(() => { - const rect = (el as Element).getBoundingClientRect() + const elements = (selectorIds ?? []) + .map((id) => mirror.getNode(id) as Element | null) + .filter((el) => el != null) + if (elements.length > 0) { + // Overlay highlight technique adapted from Playwright's highlight.ts: + // https://github.com/microsoft/playwright/blob/main/packages/injected/src/highlight.ts + // getBoundingClientRect() gives viewport-relative coords; position:fixed overlay matches. + // Simplified version: no shadow DOM glass pane, no tooltip. + iframe.contentWindow!.requestAnimationFrame(() => { + for (const el of elements) { + const rect = el.getBoundingClientRect() const overlay = doc.createElement('div') overlay.setAttribute('data-testid', 'trace-view-highlight') overlay.style.cssText = ` @@ -129,18 +131,18 @@ watch( ` overlay.style.display = showTraceSelectorHighlight.value ? '' : 'none' doc.documentElement.appendChild(overlay) - }) - } + } + }) } }, { immediate: true }, ) watch(showTraceSelectorHighlight, (show) => { - const overlay = iframeEl.value?.contentDocument?.querySelector( + const overlays = iframeEl.value?.contentDocument?.querySelectorAll( '[data-testid="trace-view-highlight"]', ) - if (overlay) { + for (const overlay of overlays ?? []) { overlay.style.display = show ? '' : 'none' } }) diff --git a/test/ui/fixtures/trace/basic.test.ts b/test/ui/fixtures/trace/basic.test.ts index 46b5dbd1d..005b8544f 100644 --- a/test/ui/fixtures/trace/basic.test.ts +++ b/test/ui/fixtures/trace/basic.test.ts @@ -18,6 +18,11 @@ test('switch-target', async () => { await page.getByRole('button').mark('Render switch target') }) +test('multiple-match', async () => { + document.body.innerHTML = '' + await page.getByRole('button').mark('Render multiple') +}) + test('popover', async () => { document.body.innerHTML = '
Popover content
' const popover = document.querySelector('[popover]')! diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index e982e5145..9c5577da4 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -26,7 +26,7 @@ test.describe('ui', () => { test.beforeEach(async ({ page }) => { await page.goto(baseURL) - await assertTestCounts(page, { pass: 13, fail: 0 }) + await assertTestCounts(page, { pass: 14, fail: 0 }) }) test('basic', async ({ page }) => { @@ -41,6 +41,10 @@ test.describe('ui', () => { await testPopover(page) }) + test('multiple match highlight', async ({ page }) => { + await testMultipleMatchHighlight(page) + }) + test('pseudo-state', async ({ page }) => { await testPseudoState(page) }) @@ -116,7 +120,7 @@ test.describe('html reporter', () => { test.beforeEach(async ({ page }) => { await page.goto(baseURL) - await assertTestCounts(page, { pass: 13, fail: 0 }) + await assertTestCounts(page, { pass: 14, fail: 0 }) }) test('basic', async ({ page }) => { @@ -131,6 +135,10 @@ test.describe('html reporter', () => { await testPopover(page) }) + test('multiple match highlight', async ({ page }) => { + await testMultipleMatchHighlight(page) + }) + test('pseudo-state', async ({ page }) => { await testPseudoState(page) }) @@ -291,6 +299,33 @@ async function testPopover(page: Page) { await expect(popoverContent).toBeHidden() } +async function testMultipleMatchHighlight(page: Page) { + await openExplorerItem(page, 'multiple-match') + + const traceView = page.getByTestId('trace-view') + const traceFrame = traceView.frameLocator('iframe') + const traceHighlight = traceFrame.getByTestId('trace-view-highlight') + const showHighlightCheckbox = traceView.getByRole('checkbox', { name: 'Show highlight' }) + await expect(traceView).toBeVisible() + await traceView.getByTestId('trace-step-name').getByText('Render multiple').click() + await expect(traceFrame.getByRole('button', { name: 'Three' })).toBeVisible() + await expect(traceHighlight).toHaveCount(3) + const buttons = traceFrame.getByRole('button') + for (let i = 0; i < 3; i++) { + expect(await traceHighlight.nth(i).boundingBox()).toEqual(await buttons.nth(i).boundingBox()) + } + + // toggle hides and shows all highlights + await showHighlightCheckbox.uncheck() + for (let i = 0; i < 3; i++) { + await expect(traceHighlight.nth(i)).toBeHidden() + } + await showHighlightCheckbox.check() + for (let i = 0; i < 3; i++) { + await expect(traceHighlight.nth(i)).toBeVisible() + } +} + async function testPseudoState(page: Page) { await openExplorerItem(page, 'pseudo-state')