diff --git a/packages/playwright-core/src/protocol/callMetadata.ts b/packages/playwright-core/src/protocol/callMetadata.ts index 4e75eda889..65a159fec7 100644 --- a/packages/playwright-core/src/protocol/callMetadata.ts +++ b/packages/playwright-core/src/protocol/callMetadata.ts @@ -28,6 +28,7 @@ export type CallMetadata = { apiName?: string; stack?: StackFrame[]; log: string[]; + afterSnapshot?: string; snapshots: { title: string, snapshotName: string }[]; error?: SerializedError; result?: any; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 5cfe760543..cf5f7f0e2a 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1281,9 +1281,10 @@ export class Frame extends SdkObject { return controller.run(async progress => { progress.log(`waiting for selector "${selector}"`); const rerunnableTask = new RerunnableTask(data, progress, injectedScript => { - return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale, omitAttached }) => { + return injectedScript.evaluateHandle((injected, { info, taskData, callbackText, querySelectorAll, logScale, omitAttached, snapshotName }) => { const callback = injected.eval(callbackText) as DomTaskBody; const poller = logScale ? injected.pollLogScale.bind(injected) : injected.pollRaf.bind(injected); + let markedElements = new Set(); return poller((progress, continuePolling) => { let element: Element | undefined; let elements: Element[] = []; @@ -1293,16 +1294,30 @@ export class Frame extends SdkObject { progress.logRepeating(` selector resolved to ${elements.length} element${elements.length === 1 ? '' : 's'}`); } else { element = injected.querySelector(info.parsed, document, info.strict); - elements = []; + elements = element ? [element] : []; if (element) progress.logRepeating(` selector resolved to ${injected.previewNode(element)}`); } if (!element && !omitAttached) return continuePolling; + + if (snapshotName) { + const previouslyMarkedElements = markedElements; + markedElements = new Set(elements); + for (const e of previouslyMarkedElements) { + if (!markedElements.has(e)) + e.removeAttribute('__playwright_target__'); + } + for (const e of markedElements) { + if (!previouslyMarkedElements.has(e)) + e.setAttribute('__playwright_target__', snapshotName); + } + } + return callback(progress, element, taskData as T, elements, continuePolling); }); - }, { info, taskData, callbackText, querySelectorAll: options.querySelectorAll, logScale: options.logScale, omitAttached: options.omitAttached }); + }, { info, taskData, callbackText, querySelectorAll: options.querySelectorAll, logScale: options.logScale, omitAttached: options.omitAttached, snapshotName: progress.metadata.afterSnapshot }); }, true); if (this._detached) diff --git a/packages/playwright-core/src/server/instrumentation.ts b/packages/playwright-core/src/server/instrumentation.ts index 6d951d56a2..927ad735a0 100644 --- a/packages/playwright-core/src/server/instrumentation.ts +++ b/packages/playwright-core/src/server/instrumentation.ts @@ -19,7 +19,7 @@ import { createGuid } from '../utils/utils'; import type { Browser } from './browser'; import type { BrowserContext } from './browserContext'; import type { BrowserType } from './browserType'; -import { ElementHandle } from './dom'; +import type { ElementHandle } from './dom'; import type { Frame } from './frames'; import type { Page } from './page'; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 3c0a549b09..e9478b613f 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -234,10 +234,17 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha return; const snapshotName = `${name}@${metadata.id}`; metadata.snapshots.push({ title: name, snapshotName }); + // We have |element| for input actions (page.click and handle.click) + // and |sdkObject| element for accessors like handle.textContent. + if (!element && sdkObject instanceof ElementHandle) + element = sdkObject; await this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName, element).catch(() => {}); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { + // Set afterSnapshot name for all the actions that operate selectors. + // Elements resolved from selectors will be marked on the snapshot. + metadata.afterSnapshot = `after@${metadata.id}`; const beforeSnapshot = this._captureSnapshot('before', sdkObject, metadata); this._pendingCalls.set(metadata.id, { sdkObject, metadata, beforeSnapshot }); await beforeSnapshot; diff --git a/tests/trace-viewer/trace-viewer.spec.ts b/tests/trace-viewer/trace-viewer.spec.ts index eb9ea0b268..432ebfc319 100644 --- a/tests/trace-viewer/trace-viewer.spec.ts +++ b/tests/trace-viewer/trace-viewer.spec.ts @@ -83,7 +83,11 @@ class TraceViewerPage { } async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise { - await this.selectAction(actionName, ordinal); + const existing = this.page.mainFrame().childFrames()[0]; + await Promise.all([ + existing ? existing.waitForNavigation() as any : Promise.resolve(), + this.selectAction(actionName, ordinal), + ]); while (this.page.frames().length < (hasSubframe ? 3 : 2)) await this.page.waitForEvent('frameattached'); return this.page.mainFrame().childFrames()[0]; @@ -497,3 +501,43 @@ test('should handle src=blob', async ({ page, server, runAndTrace, browserName } const size = await img.evaluate(e => (e as HTMLImageElement).naturalWidth); expect(size).toBe(10); }); + +test('should highlight target elements', async ({ page, runAndTrace, browserName }) => { + test.skip(browserName === 'firefox'); + + const traceViewer = await runAndTrace(async () => { + await page.setContent(` +
hello
+
world
+ `); + await page.click('text=hello'); + await page.innerText('text=hello'); + const handle = await page.$('text=hello'); + await handle.click(); + await handle.innerText(); + await page.locator('text=hello').innerText(); + await expect(page.locator('text=hello')).toHaveText(/hello/i); + await expect(page.locator('div')).toHaveText(['a', 'b'], { timeout: 1000 }).catch(() => {}); + }); + + const framePageClick = await traceViewer.snapshotFrame('page.click'); + await expect(framePageClick.locator('[__playwright_target__]')).toHaveText(['hello']); + + const framePageInnerText = await traceViewer.snapshotFrame('page.innerText'); + await expect(framePageInnerText.locator('[__playwright_target__]')).toHaveText(['hello']); + + const frameHandleClick = await traceViewer.snapshotFrame('elementHandle.click'); + await expect(frameHandleClick.locator('[__playwright_target__]')).toHaveText(['hello']); + + const frameHandleInnerText = await traceViewer.snapshotFrame('elementHandle.innerText'); + await expect(frameHandleInnerText.locator('[__playwright_target__]')).toHaveText(['hello']); + + const frameLocatorInnerText = await traceViewer.snapshotFrame('locator.innerText'); + await expect(frameLocatorInnerText.locator('[__playwright_target__]')).toHaveText(['hello']); + + const frameExpect1 = await traceViewer.snapshotFrame('expect.toHaveText', 0); + await expect(frameExpect1.locator('[__playwright_target__]')).toHaveText(['hello']); + + const frameExpect2 = await traceViewer.snapshotFrame('expect.toHaveText', 1); + await expect(frameExpect2.locator('[__playwright_target__]')).toHaveText(['hello', 'world']); +});