Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 0 additions & 10 deletions packages/playwright-core/src/server/chromium/crExecutionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 26 additions & 6 deletions packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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));
Expand All @@ -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];
Expand All @@ -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));
Expand All @@ -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 }]) => {
Expand Down Expand Up @@ -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));
Expand All @@ -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!;
Expand All @@ -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);
Expand All @@ -695,14 +712,15 @@ 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));
}

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;
Expand All @@ -714,14 +732,15 @@ 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));
}

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')
Expand Down Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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 });

Expand Down
5 changes: 2 additions & 3 deletions packages/playwright-core/src/server/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
9 changes: 0 additions & 9 deletions packages/playwright-core/src/server/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 0 additions & 5 deletions packages/playwright-core/src/server/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,7 +26,6 @@ export interface Progress {
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void;
beforeInputAction(element: ElementHandle): Promise<void>;
metadata: CallMetadata;
}

Expand Down Expand Up @@ -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
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
Loading