feat(trace): highlight strict mode violation elements in the snapshot (#32893)

This is fixing a case where the test failed with strict mode violation,
but all the matched elements are not highlighted in the trace.

For example, all the buttons will be highlighted when the following line
fails due to strict mode violation:
```ts
await page.locator('button').click();
```

To achieve this, we mark elements during `querySelector` phase instead
of inside `onBeforeInputAction`. This allows us to only mark from inside
the `InjectedScript` and remove the other way of marking from inside the
`Snapshotter`.
This commit is contained in:
Dmitry Gozman 2024-10-02 00:00:45 -07:00 committed by GitHub
parent daac0ddd24
commit 773202867d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 49 additions and 95 deletions

View File

@ -77,10 +77,6 @@ export class BidiExecutionContext implements js.ExecutionContextDelegate {
throw new js.JavaScriptErrorInEvaluate('Unexpected response type: ' + JSON.stringify(response));
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
throw new Error('Method not implemented.');
}
async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const response = await this._session.send('script.callFunction', {
functionDeclaration,

View File

@ -53,16 +53,6 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
return remoteObject.objectId!;
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
this._client.send('Runtime.callFunctionOn', {
functionDeclaration: func.toString(),
arguments: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }),
returnByValue: true,
executionContextId: this._contextId,
userGesture: true
}).catch(() => {});
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: expression,

View File

@ -421,7 +421,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return maybePoint;
const point = roundPoint(maybePoint);
progress.metadata.point = point;
await progress.beforeInputAction(this);
await this.instrumentation.onBeforeInputAction(this, progress.metadata);
let hitTargetInterceptionHandle: js.JSHandle<HitTargetInterceptionResult> | undefined;
if (force) {
@ -490,9 +490,19 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return 'done';
}
private async _markAsTargetElement(metadata: CallMetadata) {
if (!metadata.id)
return;
await this.evaluateInUtility(([injected, node, callId]) => {
if (node.nodeType === 1 /* Node.ELEMENT_NODE */)
injected.markTargetElements(new Set([node as Node as Element]), callId);
}, metadata.id);
}
async hover(metadata: CallMetadata, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._hover(progress, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -505,6 +515,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async click(metadata: CallMetadata, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter });
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -517,6 +528,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async dblclick(metadata: CallMetadata, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._dblclick(progress, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -529,6 +541,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async tap(metadata: CallMetadata, options: types.PointerActionWaitOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._tap(progress, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -541,6 +554,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async selectOption(metadata: CallMetadata, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise<string[]> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._selectOption(progress, elements, values, options);
return throwRetargetableDOMError(result);
}, this._page._timeoutSettings.timeout(options));
@ -549,7 +563,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise<string[] | 'error:notconnected'> {
let resultingOptions: string[] = [];
await this._retryAction(progress, 'select option', async () => {
await progress.beforeInputAction(this);
await this.instrumentation.onBeforeInputAction(this, progress.metadata);
if (!options.force)
progress.log(` waiting for element to be visible and enabled`);
const optionsToSelect = [...elements, ...values];
@ -574,6 +588,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async fill(metadata: CallMetadata, value: string, options: types.CommonActionOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._fill(progress, value, options);
assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -582,7 +597,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _fill(progress: Progress, value: string, options: types.CommonActionOptions): Promise<'error:notconnected' | 'done'> {
progress.log(` fill("${value}")`);
return await this._retryAction(progress, 'fill', async () => {
await progress.beforeInputAction(this);
await this.instrumentation.onBeforeInputAction(this, progress.metadata);
if (!options.force)
progress.log(' waiting for element to be visible, enabled and editable');
const result = await this.evaluateInUtility(async ([injected, node, { value, force }]) => {
@ -629,6 +644,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const inputFileItems = await prepareFilesForUpload(this._frame, params);
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._setInputFiles(progress, inputFileItems);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(params));
@ -655,7 +671,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (result === 'error:notconnected' || !result.asElement())
return 'error:notconnected';
const retargeted = result.asElement() as ElementHandle<HTMLInputElement>;
await progress.beforeInputAction(this);
await this.instrumentation.onBeforeInputAction(this, progress.metadata);
progress.throwIfAborted(); // Avoid action that has side-effects.
if (localPaths || localDirectory) {
const localPathsOrDirectory = localDirectory ? [localDirectory] : localPaths!;
@ -677,6 +693,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async focus(metadata: CallMetadata): Promise<void> {
const controller = new ProgressController(metadata, this);
await controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._focus(progress);
return assertDone(throwRetargetableDOMError(result));
}, 0);
@ -695,6 +712,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async type(metadata: CallMetadata, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._type(progress, text, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -702,7 +720,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _type(progress: Progress, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise<'error:notconnected' | 'done'> {
progress.log(`elementHandle.type("${text}")`);
await progress.beforeInputAction(this);
await this.instrumentation.onBeforeInputAction(this, progress.metadata);
const result = await this._focus(progress, true /* resetSelectionIfNotFocused */);
if (result !== 'done')
return result;
@ -714,6 +732,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async press(metadata: CallMetadata, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise<void> {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._press(progress, key, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options));
@ -721,7 +740,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _press(progress: Progress, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise<'error:notconnected' | 'done'> {
progress.log(`elementHandle.press("${key}")`);
await progress.beforeInputAction(this);
await this.instrumentation.onBeforeInputAction(this, progress.metadata);
return this._page._frameManager.waitForSignalsCreatedBy(progress, !options.noWaitAfter, async () => {
const result = await this._focus(progress, true /* resetSelectionIfNotFocused */);
if (result !== 'done')
@ -753,6 +772,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(result);
};
await this._markAsTargetElement(progress.metadata);
if (await isChecked() === state)
return 'done';
const result = await this._click(progress, { ...options, waitAfter: 'disabled' });

View File

@ -51,15 +51,6 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
return payload.result!.objectId!;
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
this._session.send('Runtime.callFunction', {
functionDeclaration: func.toString(),
args: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }) as any,
returnByValue: true,
executionContextId: this._executionContextId
}).catch(() => {});
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: expression,

View File

@ -1124,8 +1124,10 @@ export class Frame extends SdkObject {
progress.throwIfAborted();
if (!resolved)
return continuePolling;
const result = await resolved.injected.evaluateHandle((injected, { info }) => {
const result = await resolved.injected.evaluateHandle((injected, { info, callId }) => {
const elements = injected.querySelectorAll(info.parsed, document);
if (callId)
injected.markTargetElements(new Set(elements), callId);
const element = elements[0] as Element | undefined;
let log = '';
if (elements.length > 1) {
@ -1136,7 +1138,7 @@ export class Frame extends SdkObject {
log = ` locator resolved to ${injected.previewNode(element)}`;
}
return { log, success: !!element, element };
}, { info: resolved.info });
}, { info: resolved.info, callId: progress.metadata.id });
const { log, success } = await result.evaluate(r => ({ log: r.log, success: r.success }));
if (log)
progress.log(log);
@ -1478,6 +1480,8 @@ export class Frame extends SdkObject {
const { log, matches, received, missingReceived } = await injected.evaluate(async (injected, { info, options, callId }) => {
const elements = info ? injected.querySelectorAll(info.parsed, document) : [];
if (callId)
injected.markTargetElements(new Set(elements), callId);
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
let log = '';
if (isArray)
@ -1486,8 +1490,6 @@ export class Frame extends SdkObject {
throw injected.strictModeViolationError(info!.parsed, elements);
else if (elements.length)
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
if (callId)
injected.markTargetElements(new Set(elements), callId);
return { log, ...await injected.expect(elements[0], options, elements) };
}, { info, options, callId: progress.metadata.id });

View File

@ -20,7 +20,6 @@ import type { APIRequestContext } from './fetch';
import type { Browser } from './browser';
import type { BrowserContext } from './browserContext';
import type { BrowserType } from './browserType';
import type { ElementHandle } from './dom';
import type { Frame } from './frames';
import type { Page } from './page';
import type { Playwright } from './playwright';
@ -57,7 +56,7 @@ export interface Instrumentation {
addListener(listener: InstrumentationListener, context: BrowserContext | APIRequestContext | null): void;
removeListener(listener: InstrumentationListener): void;
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void;
onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onPageOpen(page: Page): void;
@ -70,7 +69,7 @@ export interface Instrumentation {
export interface InstrumentationListener {
onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onCallLog?(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void;
onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onPageOpen?(page: Page): void;

View File

@ -53,7 +53,6 @@ export type SmartHandle<T> = T extends Node ? dom.ElementHandle<T> : JSHandle<T>
export interface ExecutionContextDelegate {
rawEvaluateJSON(expression: string): Promise<any>;
rawEvaluateHandle(expression: string): Promise<ObjectId>;
rawCallFunctionNoReply(func: Function, ...args: any[]): void;
evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any>;
getProperties(context: ExecutionContext, objectId: ObjectId): Promise<Map<string, JSHandle>>;
createHandle(context: ExecutionContext, remoteObject: RemoteObject): JSHandle;
@ -88,10 +87,6 @@ export class ExecutionContext extends SdkObject {
return this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(expression));
}
rawCallFunctionNoReply(func: Function, ...args: any[]): void {
this._delegate.rawCallFunctionNoReply(func, ...args);
}
evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any> {
return this._raceAgainstContextDestroyed(this._delegate.evaluateWithArguments(expression, returnByValue, utilityScript, values, objectIds));
}
@ -151,10 +146,6 @@ export class JSHandle<T = any> extends SdkObject {
(globalThis as any).leakedJSHandles.set(this, new Error('Leaked JSHandle'));
}
callFunctionNoReply(func: Function, arg: any) {
this._context.rawCallFunctionNoReply(func, this, arg);
}
async evaluate<R, Arg>(pageFunction: FuncOn<T, Arg, R>, arg?: Arg): Promise<R> {
return evaluate(this._context, true /* returnByValue */, pageFunction, this, arg);
}

View File

@ -18,7 +18,6 @@ import { TimeoutError } from './errors';
import { assert, monotonicTime } from '../utils';
import type { LogName } from '../utils/debugLogger';
import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation';
import type { ElementHandle } from './dom';
import { ManualPromise } from '../utils/manualPromise';
export interface Progress {
@ -27,7 +26,6 @@ export interface Progress {
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void;
beforeInputAction(element: ElementHandle): Promise<void>;
metadata: CallMetadata;
}
@ -89,9 +87,6 @@ export class ProgressController {
if (this._state === 'aborted')
throw new AbortedError();
},
beforeInputAction: async (element: ElementHandle) => {
await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata, element);
},
metadata: this.metadata
};

View File

@ -24,7 +24,6 @@ import type { SnapshotData } from './snapshotterInjected';
import { frameSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../../utils';
import type { FrameSnapshot } from '@trace/snapshot';
import type { ElementHandle } from '../../dom';
import { mime } from '../../../utilsBundle';
export type SnapshotterBlob = {
@ -105,21 +104,10 @@ export class Snapshotter {
eventsHelper.removeEventListeners(this._eventListeners);
}
async captureSnapshot(page: Page, callId: string, snapshotName: string, element?: ElementHandle): Promise<void> {
async captureSnapshot(page: Page, callId: string, snapshotName: string): Promise<void> {
// Prepare expression synchronously.
const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`;
// In a best-effort manner, without waiting for it, mark target element.
element?.callFunctionNoReply((element: Element, callId: string) => {
const customEvent = new CustomEvent('__playwright_target__', {
bubbles: true,
cancelable: true,
detail: callId,
composed: true,
});
element.dispatchEvent(customEvent);
}, callId);
// In each frame, in a non-stalling manner, capture the snapshots.
const snapshots = page.frames().map(async frame => {
const data = await frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(e => debugLogger.log('error', e)) as SnapshotData;

View File

@ -23,7 +23,6 @@ import { commandsWithTracingSnapshots } from '../../../protocol/debug';
import { assert, createGuid, monotonicTime, SerializedFS, removeFolders, eventsHelper, type RegisteredListener } from '../../../utils';
import { Artifact } from '../../artifact';
import { BrowserContext } from '../../browserContext';
import type { ElementHandle } from '../../dom';
import type { APIRequestContext } from '../../fetch';
import type { CallMetadata, InstrumentationListener } from '../../instrumentation';
import { SdkObject } from '../../instrumentation';
@ -341,7 +340,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return { artifact };
}
async _captureSnapshot(snapshotName: string, sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise<void> {
async _captureSnapshot(snapshotName: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!this._snapshotter)
return;
if (!sdkObject.attribution.page)
@ -350,7 +349,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return;
if (!shouldCaptureSnapshot(metadata))
return;
await this._snapshotter.captureSnapshot(sdkObject.attribution.page, metadata.id, snapshotName, element).catch(() => {});
await this._snapshotter.captureSnapshot(sdkObject.attribution.page, metadata.id, snapshotName).catch(() => {});
}
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
@ -365,7 +364,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return this._captureSnapshot(event.beforeSnapshot, sdkObject, metadata);
}
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle) {
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) {
if (!this._state?.callIds.has(metadata.id))
return Promise.resolve();
// IMPORTANT: no awaits before this._appendTraceEvent in this method.
@ -375,7 +374,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
sdkObject.attribution.page?.temporarilyDisableTracingScreencastThrottling();
event.inputSnapshot = `input@${metadata.id}`;
this._appendTraceEvent(event);
return this._captureSnapshot(event.inputSnapshot, sdkObject, metadata, element);
return this._captureSnapshot(event.inputSnapshot, sdkObject, metadata);
}
onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string) {

View File

@ -21,7 +21,6 @@ import type { SnapshotRenderer } from '../../../../../trace-viewer/src/sw/snapsh
import { SnapshotStorage } from '../../../../../trace-viewer/src/sw/snapshotStorage';
import type { SnapshotterBlob, SnapshotterDelegate } from '../recorder/snapshotter';
import { Snapshotter } from '../recorder/snapshotter';
import type { ElementHandle } from '../../dom';
import type { HarTracerDelegate } from '../../har/harTracer';
import { HarTracer } from '../../har/harTracer';
import type * as har from '@trace/har';
@ -59,11 +58,11 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega
this._harTracer.stop();
}
async captureSnapshot(page: Page, callId: string, snapshotName: string, element?: ElementHandle): Promise<SnapshotRenderer> {
async captureSnapshot(page: Page, callId: string, snapshotName: string): Promise<SnapshotRenderer> {
if (this._snapshotReadyPromises.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + snapshotName);
this._snapshotter.captureSnapshot(page, callId, snapshotName, element).catch(() => {});
this._snapshotter.captureSnapshot(page, callId, snapshotName).catch(() => {});
const promise = new ManualPromise<SnapshotRenderer>();
this._snapshotReadyPromises.set(snapshotName, promise);
return promise;

View File

@ -60,16 +60,6 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
this._session.send('Runtime.callFunctionOn', {
functionDeclaration: func.toString(),
objectId: args.find(a => a instanceof js.JSHandle)!._objectId!,
arguments: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }),
returnByValue: true,
emulateUserGesture: true
}).catch(() => {});
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
try {
const response = await this._session.send('Runtime.callFunctionOn', {

View File

@ -215,20 +215,6 @@ it.describe('snapshots', () => {
}
});
it('should capture snapshot target', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button><button>World</button>');
{
const handle = await page.$('text=Hello');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@1', 'snapshot@call@1', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"call@1\">Hello</BUTTON><BUTTON>World</BUTTON>');
}
{
const handle = await page.$('text=World');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'call@2', 'snapshot@call@2', toImpl(handle));
expect(distillSnapshot(snapshot, false /* distillTarget */)).toBe('<BUTTON __playwright_target__=\"call@1\">Hello</BUTTON><BUTTON __playwright_target__=\"call@2\">World</BUTTON>');
}
});
it('should collect on attribute change', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button>');
{

View File

@ -776,6 +776,8 @@ test('should highlight target elements', async ({ page, runAndTrace, browserName
await expect(page.locator('text=t6')).toHaveText(/t6/i);
await expect(page.locator('text=multi')).toHaveText(['a', 'b'], { timeout: 1000 }).catch(() => {});
await page.mouse.move(123, 234);
await page.getByText(/^t\d$/).click().catch(() => {});
await expect(page.getByText(/t3|t4/)).toBeVisible().catch(() => {});
});
async function highlightedDivs(frameLocator: FrameLocator) {
@ -817,6 +819,12 @@ test('should highlight target elements', async ({ page, runAndTrace, browserName
const frameMouseMove = await traceViewer.snapshotFrame('mouse.move');
await expect(frameMouseMove.locator('x-pw-pointer')).toBeVisible();
const frameClickStrictViolation = await traceViewer.snapshotFrame('locator.click');
await expect.poll(() => highlightedDivs(frameClickStrictViolation)).toEqual(['t1', 't2', 't3', 't4', 't5', 't6']);
const frameExpectStrictViolation = await traceViewer.snapshotFrame('expect.toBeVisible');
await expect.poll(() => highlightedDivs(frameExpectStrictViolation)).toEqual(['t3', 't4']);
});
test('should highlight target element in shadow dom', async ({ page, server, runAndTrace }) => {