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

fix(runner): Properly attribute error messages to locator handlers #34074

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
10 changes: 7 additions & 3 deletions packages/playwright-core/src/client/channelOwner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { zones } from '../utils/zones';
import type { ClientInstrumentation } from './clientInstrumentation';
import type { Connection } from './connection';
import type { Logger } from './types';
import { isOverriddenAPIError } from './errors';

type Listener = (...args: any[]) => void;

Expand Down Expand Up @@ -203,15 +204,18 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
return result;
} catch (e) {
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
if (apiName && !apiName.includes('<anonymous>'))
e.message = apiName + ': ' + e.message;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have a very good reason to change the fundamentals (channelOwner) for a niche DevX like the one in the bug. This isn't even an end-user reported feature, so I'd rather minimize the maintenance cost.

const computedAPIName = isOverriddenAPIError(e) ? e.apiNameOverride : apiName;

if (computedAPIName && !computedAPIName.includes('<anonymous>'))
e.message = computedAPIName + ': ' + e.message;
const stackFrames = '\n' + stringifyStackFrames(stackTrace.frames).join('\n') + innerError;
if (stackFrames.trim())
e.stack = e.message + stackFrames;
else
e.stack = '';
csi?.onApiCallEnd(callCookie, e);
logApiCall(logger, `<= ${apiName} failed`, isInternal);
logApiCall(logger, `<= ${computedAPIName} failed`, isInternal);
throw e;
}
}
Expand Down
21 changes: 21 additions & 0 deletions packages/playwright-core/src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ export function isTargetClosedError(error: Error) {
return error instanceof TargetClosedError;
}

export class OverriddenAPIError extends Error {
public apiNameOverride: string;

constructor(error: Exclude<SerializedError['error'], undefined>, apiNameOverride: string) {
super(error.message);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have 4 layers of processing of the api names and errors, adding another one for this use case will add to the confusion.

this.name = error.name;
this.stack = error.stack;
this.apiNameOverride = apiNameOverride;
}
}

export function isOverriddenAPIError(error: Error) {
return error instanceof OverriddenAPIError;
}

export function serializeError(e: any): SerializedError {
if (isError(e))
return { error: { message: e.message, stack: e.stack, name: e.name } };
Expand All @@ -47,6 +62,12 @@ export function parseError(error: SerializedError): Error {
throw new Error('Serialized error must have either an error or a value');
return parseSerializedValue(error.value, undefined);
}
if (error.error.apiNameOverride) {
const e = new OverriddenAPIError(error.error, error.error.apiNameOverride);
e.stack = error.error.stack;
e.name = error.error.name;
return e;
}
if (error.error.name === 'TimeoutError') {
const e = new TimeoutError(error.error.message);
e.stack = error.error.stack || '';
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ scheme.SerializedError = tObject({
message: tString,
name: tString,
stack: tOptional(tString),
apiNameOverride: tOptional(tString),
})),
value: tOptional(tType('SerializedValue')),
});
Expand Down
39 changes: 37 additions & 2 deletions packages/playwright-core/src/server/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type { SerializedError } from '@protocol/channels';
import { isError } from '../utils';
import { parseSerializedValue, serializeValue } from '../protocol/serializers';
import { JavaScriptErrorInEvaluate } from './javascript';

class CustomError extends Error {
constructor(message: string) {
Expand All @@ -37,9 +38,43 @@ export function isTargetClosedError(error: Error) {
return error instanceof TargetClosedError || error.name === 'TargetClosedError';
}

export class OverriddenAPIError extends JavaScriptErrorInEvaluate {
public apiNameOverride: string;

constructor(error: JavaScriptErrorInEvaluate, apiNameOverride: string) {
super(error.message);
this.name = error.name;
this.stack = error.stack;
this.apiNameOverride = apiNameOverride;
}
}

export function isOverriddenAPIError(error: Error) {
return error instanceof OverriddenAPIError;
}

export function serializeError(e: any): SerializedError {
if (isError(e))
return { error: { message: e.message, stack: e.stack, name: e.name } };
if (isError(e)) {
const serializedError: {
error: {
message: any,
stack: any,
name: string,
apiNameOverride?: string;
}
} = {
error: {
message: e.message,
stack: e.stack,
name: e.name
}
};

if (isOverriddenAPIError(e))
serializedError.error.apiNameOverride = e.apiNameOverride;

return serializedError;
}
return { value: serializeValue(e, value => ({ fallThrough: value })) };
}

Expand Down
50 changes: 28 additions & 22 deletions packages/playwright-core/src/server/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import type { TimeoutOptions } from '../common/types';
import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser';
import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers';
import type { SerializedValue } from './isomorphic/utilityScriptSerializers';
import { TargetClosedError, TimeoutError } from './errors';
import { OverriddenAPIError, TargetClosedError, TimeoutError } from './errors';
import { asLocator } from '../utils';
import { helper } from './helper';

Expand Down Expand Up @@ -494,30 +494,36 @@ export class Page extends SdkObject {
// Do not run locator handlers from inside locator handler callbacks to avoid deadlocks.
if (this._locatorHandlerRunningCounter)
return;
for (const [uid, handler] of this._locatorHandlers) {
if (!handler.resolved) {
if (await this.mainFrame().isVisibleInternal(handler.selector, { strict: true })) {
handler.resolved = new ManualPromise();
this.emit(Page.Events.LocatorHandlerTriggered, uid);
try {
for (const [uid, handler] of this._locatorHandlers) {
if (!handler.resolved) {
if (await this.mainFrame().isVisibleInternal(handler.selector, { strict: true })) {
handler.resolved = new ManualPromise();
this.emit(Page.Events.LocatorHandlerTriggered, uid);
}
}
}
if (handler.resolved) {
++this._locatorHandlerRunningCounter;
progress.log(` found ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)}, intercepting action to run the handler`);
const promise = handler.resolved.then(async () => {
if (handler.resolved) {
++this._locatorHandlerRunningCounter;
progress.log(` found ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)}, intercepting action to run the handler`);
const promise = handler.resolved.then(async () => {
progress.throwIfAborted();
if (!handler.noWaitAfter) {
progress.log(` locator handler has finished, waiting for ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)} to be hidden`);
await this.mainFrame().waitForSelectorInternal(progress, handler.selector, false, { state: 'hidden' });
} else {
progress.log(` locator handler has finished`);
}
});
await this.openScope.race(promise).finally(() => --this._locatorHandlerRunningCounter);
// Avoid side-effects after long-running operation.
progress.throwIfAborted();
if (!handler.noWaitAfter) {
progress.log(` locator handler has finished, waiting for ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)} to be hidden`);
await this.mainFrame().waitForSelectorInternal(progress, handler.selector, false, { state: 'hidden' });
} else {
progress.log(` locator handler has finished`);
}
});
await this.openScope.race(promise).finally(() => --this._locatorHandlerRunningCounter);
// Avoid side-effects after long-running operation.
progress.throwIfAborted();
progress.log(` interception handler has finished, continuing`);
progress.log(` interception handler has finished, continuing`);
}
}
} catch (e) {
// Since this checkpoint occurs during the evaluation of a different public API call, any errors are automatically attributed
// to that API call, rather than the locator handler. Mark the error from the locator handler
throw new OverriddenAPIError(e, 'page.addLocatorHandler');
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export type SerializedError = {
message: string,
name: string,
stack?: string,
apiNameOverride?: string,
},
value?: SerializedValue,
};
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ SerializedError:
message: string
name: string
stack: string?
apiNameOverride: string?
value: SerializedValue?


Expand Down
16 changes: 16 additions & 0 deletions tests/page/page-add-locator-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,3 +371,19 @@ test('should removeLocatorHandler', async ({ page, server }) => {
await expect(page.locator('#interstitial')).toBeVisible();
expect(error.message).toContain('Timeout 3000ms exceeded');
});

test('should attribute errors to the locator handler', async ({ page }) => {
await page.setContent(`<html><body><script>setTimeout(() => {document.body.innerHTML = '<div><ul><li>Foo</li></ul><ul><li>Bar</li></ul></div>'}, 100)</script></body></html>`);

await page.addLocatorHandler(page.locator('ul'), async locator => Promise.resolve());

try {
// Perform an operation that will trigger the locator handler
await page.locator('ul > li').first().boundingBox();

// Unreachable
expect(false).toBeTruthy();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's a nicer way to handle testing for an exception, please let me know. I couldn't find a way for expect.toThrow() to be async.

} catch (e) {
expect(e.message).toContain(`page.addLocatorHandler: Error: strict mode violation: locator('ul') resolved to 2 elements:`);
}
});
Loading