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)); 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> { async evaluateWithArguments(functionDeclaration: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const response = await this._session.send('script.callFunction', { const response = await this._session.send('script.callFunction', {
functionDeclaration, functionDeclaration,

View File

@ -53,16 +53,6 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
return remoteObject.objectId!; 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> { 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', { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: expression, functionDeclaration: expression,

View File

@ -421,7 +421,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return maybePoint; return maybePoint;
const point = roundPoint(maybePoint); const point = roundPoint(maybePoint);
progress.metadata.point = point; progress.metadata.point = point;
await progress.beforeInputAction(this); await this.instrumentation.onBeforeInputAction(this, progress.metadata);
let hitTargetInterceptionHandle: js.JSHandle<HitTargetInterceptionResult> | undefined; let hitTargetInterceptionHandle: js.JSHandle<HitTargetInterceptionResult> | undefined;
if (force) { if (force) {
@ -490,9 +490,19 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return 'done'; 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> { async hover(metadata: CallMetadata, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._hover(progress, options); const result = await this._hover(progress, options);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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> { async click(metadata: CallMetadata, options: { noWaitAfter?: boolean } & types.MouseClickOptions & types.PointerActionWaitOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter }); const result = await this._click(progress, { ...options, waitAfter: !options.noWaitAfter });
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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> { async dblclick(metadata: CallMetadata, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._dblclick(progress, options); const result = await this._dblclick(progress, options);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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> { async tap(metadata: CallMetadata, options: types.PointerActionWaitOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._tap(progress, options); const result = await this._tap(progress, options);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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[]> { async selectOption(metadata: CallMetadata, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise<string[]> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._selectOption(progress, elements, values, options); const result = await this._selectOption(progress, elements, values, options);
return throwRetargetableDOMError(result); return throwRetargetableDOMError(result);
}, this._page._timeoutSettings.timeout(options)); }, 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'> { async _selectOption(progress: Progress, elements: ElementHandle[], values: types.SelectOption[], options: types.CommonActionOptions): Promise<string[] | 'error:notconnected'> {
let resultingOptions: string[] = []; let resultingOptions: string[] = [];
await this._retryAction(progress, 'select option', async () => { await this._retryAction(progress, 'select option', async () => {
await progress.beforeInputAction(this); await this.instrumentation.onBeforeInputAction(this, progress.metadata);
if (!options.force) if (!options.force)
progress.log(` waiting for element to be visible and enabled`); progress.log(` waiting for element to be visible and enabled`);
const optionsToSelect = [...elements, ...values]; 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> { async fill(metadata: CallMetadata, value: string, options: types.CommonActionOptions = {}): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._fill(progress, value, options); const result = await this._fill(progress, value, options);
assertDone(throwRetargetableDOMError(result)); assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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'> { async _fill(progress: Progress, value: string, options: types.CommonActionOptions): Promise<'error:notconnected' | 'done'> {
progress.log(` fill("${value}")`); progress.log(` fill("${value}")`);
return await this._retryAction(progress, 'fill', async () => { return await this._retryAction(progress, 'fill', async () => {
await progress.beforeInputAction(this); await this.instrumentation.onBeforeInputAction(this, progress.metadata);
if (!options.force) if (!options.force)
progress.log(' waiting for element to be visible, enabled and editable'); progress.log(' waiting for element to be visible, enabled and editable');
const result = await this.evaluateInUtility(async ([injected, node, { value, force }]) => { 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 inputFileItems = await prepareFilesForUpload(this._frame, params);
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._setInputFiles(progress, inputFileItems); const result = await this._setInputFiles(progress, inputFileItems);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(params)); }, 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()) if (result === 'error:notconnected' || !result.asElement())
return 'error:notconnected'; return 'error:notconnected';
const retargeted = result.asElement() as ElementHandle<HTMLInputElement>; 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. progress.throwIfAborted(); // Avoid action that has side-effects.
if (localPaths || localDirectory) { if (localPaths || localDirectory) {
const localPathsOrDirectory = localDirectory ? [localDirectory] : localPaths!; 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> { async focus(metadata: CallMetadata): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
await controller.run(async progress => { await controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._focus(progress); const result = await this._focus(progress);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, 0); }, 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> { async type(metadata: CallMetadata, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._type(progress, text, options); const result = await this._type(progress, text, options);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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'> { async _type(progress: Progress, text: string, options: { delay?: number } & types.TimeoutOptions & types.StrictOptions): Promise<'error:notconnected' | 'done'> {
progress.log(`elementHandle.type("${text}")`); progress.log(`elementHandle.type("${text}")`);
await progress.beforeInputAction(this); await this.instrumentation.onBeforeInputAction(this, progress.metadata);
const result = await this._focus(progress, true /* resetSelectionIfNotFocused */); const result = await this._focus(progress, true /* resetSelectionIfNotFocused */);
if (result !== 'done') if (result !== 'done')
return result; 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> { async press(metadata: CallMetadata, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise<void> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
await this._markAsTargetElement(metadata);
const result = await this._press(progress, key, options); const result = await this._press(progress, key, options);
return assertDone(throwRetargetableDOMError(result)); return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options)); }, 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'> { async _press(progress: Progress, key: string, options: { delay?: number, noWaitAfter?: boolean } & types.TimeoutOptions & types.StrictOptions): Promise<'error:notconnected' | 'done'> {
progress.log(`elementHandle.press("${key}")`); 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 () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, !options.noWaitAfter, async () => {
const result = await this._focus(progress, true /* resetSelectionIfNotFocused */); const result = await this._focus(progress, true /* resetSelectionIfNotFocused */);
if (result !== 'done') 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'), {}); const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(result); return throwRetargetableDOMError(result);
}; };
await this._markAsTargetElement(progress.metadata);
if (await isChecked() === state) if (await isChecked() === state)
return 'done'; return 'done';
const result = await this._click(progress, { ...options, waitAfter: 'disabled' }); 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!; 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> { async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const payload = await this._session.send('Runtime.callFunction', { const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: expression, functionDeclaration: expression,

View File

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

View File

@ -20,7 +20,6 @@ import type { APIRequestContext } from './fetch';
import type { Browser } from './browser'; import type { Browser } from './browser';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import type { BrowserType } from './browserType'; import type { BrowserType } from './browserType';
import type { ElementHandle } from './dom';
import type { Frame } from './frames'; import type { Frame } from './frames';
import type { Page } from './page'; import type { Page } from './page';
import type { Playwright } from './playwright'; import type { Playwright } from './playwright';
@ -57,7 +56,7 @@ export interface Instrumentation {
addListener(listener: InstrumentationListener, context: BrowserContext | APIRequestContext | null): void; addListener(listener: InstrumentationListener, context: BrowserContext | APIRequestContext | null): void;
removeListener(listener: InstrumentationListener): void; removeListener(listener: InstrumentationListener): void;
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<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; onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void;
onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onPageOpen(page: Page): void; onPageOpen(page: Page): void;
@ -70,7 +69,7 @@ export interface Instrumentation {
export interface InstrumentationListener { export interface InstrumentationListener {
onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<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; onCallLog?(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): void;
onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>; onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onPageOpen?(page: Page): 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 { export interface ExecutionContextDelegate {
rawEvaluateJSON(expression: string): Promise<any>; rawEvaluateJSON(expression: string): Promise<any>;
rawEvaluateHandle(expression: string): Promise<ObjectId>; 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>; evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any>;
getProperties(context: ExecutionContext, objectId: ObjectId): Promise<Map<string, JSHandle>>; getProperties(context: ExecutionContext, objectId: ObjectId): Promise<Map<string, JSHandle>>;
createHandle(context: ExecutionContext, remoteObject: RemoteObject): JSHandle; createHandle(context: ExecutionContext, remoteObject: RemoteObject): JSHandle;
@ -88,10 +87,6 @@ export class ExecutionContext extends SdkObject {
return this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(expression)); 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> { 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)); 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')); (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> { async evaluate<R, Arg>(pageFunction: FuncOn<T, Arg, R>, arg?: Arg): Promise<R> {
return evaluate(this._context, true /* returnByValue */, pageFunction, this, arg); 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 { assert, monotonicTime } from '../utils';
import type { LogName } from '../utils/debugLogger'; import type { LogName } from '../utils/debugLogger';
import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation'; import type { CallMetadata, Instrumentation, SdkObject } from './instrumentation';
import type { ElementHandle } from './dom';
import { ManualPromise } from '../utils/manualPromise'; import { ManualPromise } from '../utils/manualPromise';
export interface Progress { export interface Progress {
@ -27,7 +26,6 @@ export interface Progress {
isRunning(): boolean; isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void; cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void; throwIfAborted(): void;
beforeInputAction(element: ElementHandle): Promise<void>;
metadata: CallMetadata; metadata: CallMetadata;
} }
@ -89,9 +87,6 @@ export class ProgressController {
if (this._state === 'aborted') if (this._state === 'aborted')
throw new AbortedError(); throw new AbortedError();
}, },
beforeInputAction: async (element: ElementHandle) => {
await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata, element);
},
metadata: this.metadata metadata: this.metadata
}; };

View File

@ -24,7 +24,6 @@ import type { SnapshotData } from './snapshotterInjected';
import { frameSnapshotStreamer } from './snapshotterInjected'; import { frameSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../../utils'; import { calculateSha1, createGuid, monotonicTime } from '../../../utils';
import type { FrameSnapshot } from '@trace/snapshot'; import type { FrameSnapshot } from '@trace/snapshot';
import type { ElementHandle } from '../../dom';
import { mime } from '../../../utilsBundle'; import { mime } from '../../../utilsBundle';
export type SnapshotterBlob = { export type SnapshotterBlob = {
@ -105,21 +104,10 @@ export class Snapshotter {
eventsHelper.removeEventListeners(this._eventListeners); 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. // Prepare expression synchronously.
const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`; 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. // In each frame, in a non-stalling manner, capture the snapshots.
const snapshots = page.frames().map(async frame => { const snapshots = page.frames().map(async frame => {
const data = await frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(e => debugLogger.log('error', e)) as SnapshotData; 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 { assert, createGuid, monotonicTime, SerializedFS, removeFolders, eventsHelper, type RegisteredListener } from '../../../utils';
import { Artifact } from '../../artifact'; import { Artifact } from '../../artifact';
import { BrowserContext } from '../../browserContext'; import { BrowserContext } from '../../browserContext';
import type { ElementHandle } from '../../dom';
import type { APIRequestContext } from '../../fetch'; import type { APIRequestContext } from '../../fetch';
import type { CallMetadata, InstrumentationListener } from '../../instrumentation'; import type { CallMetadata, InstrumentationListener } from '../../instrumentation';
import { SdkObject } from '../../instrumentation'; import { SdkObject } from '../../instrumentation';
@ -341,7 +340,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return { artifact }; 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) if (!this._snapshotter)
return; return;
if (!sdkObject.attribution.page) if (!sdkObject.attribution.page)
@ -350,7 +349,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return; return;
if (!shouldCaptureSnapshot(metadata)) if (!shouldCaptureSnapshot(metadata))
return; 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) { onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
@ -365,7 +364,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
return this._captureSnapshot(event.beforeSnapshot, sdkObject, metadata); 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)) if (!this._state?.callIds.has(metadata.id))
return Promise.resolve(); return Promise.resolve();
// IMPORTANT: no awaits before this._appendTraceEvent in this method. // 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(); sdkObject.attribution.page?.temporarilyDisableTracingScreencastThrottling();
event.inputSnapshot = `input@${metadata.id}`; event.inputSnapshot = `input@${metadata.id}`;
this._appendTraceEvent(event); 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) { 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 { SnapshotStorage } from '../../../../../trace-viewer/src/sw/snapshotStorage';
import type { SnapshotterBlob, SnapshotterDelegate } from '../recorder/snapshotter'; import type { SnapshotterBlob, SnapshotterDelegate } from '../recorder/snapshotter';
import { Snapshotter } from '../recorder/snapshotter'; import { Snapshotter } from '../recorder/snapshotter';
import type { ElementHandle } from '../../dom';
import type { HarTracerDelegate } from '../../har/harTracer'; import type { HarTracerDelegate } from '../../har/harTracer';
import { HarTracer } from '../../har/harTracer'; import { HarTracer } from '../../har/harTracer';
import type * as har from '@trace/har'; import type * as har from '@trace/har';
@ -59,11 +58,11 @@ export class InMemorySnapshotter implements SnapshotterDelegate, HarTracerDelega
this._harTracer.stop(); 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)) if (this._snapshotReadyPromises.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + 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>(); const promise = new ManualPromise<SnapshotRenderer>();
this._snapshotReadyPromises.set(snapshotName, promise); this._snapshotReadyPromises.set(snapshotName, promise);
return 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> { async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
try { try {
const response = await this._session.send('Runtime.callFunctionOn', { 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 }) => { it('should collect on attribute change', async ({ page, toImpl, snapshotter }) => {
await page.setContent('<button>Hello</button>'); 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=t6')).toHaveText(/t6/i);
await expect(page.locator('text=multi')).toHaveText(['a', 'b'], { timeout: 1000 }).catch(() => {}); await expect(page.locator('text=multi')).toHaveText(['a', 'b'], { timeout: 1000 }).catch(() => {});
await page.mouse.move(123, 234); 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) { 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'); const frameMouseMove = await traceViewer.snapshotFrame('mouse.move');
await expect(frameMouseMove.locator('x-pw-pointer')).toBeVisible(); 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 }) => { test('should highlight target element in shadow dom', async ({ page, server, runAndTrace }) => {