feat(addLocatorHandler): various improvements (#30494)

- Automatically waiting for the overlay locator to be hidden, with
`allowStayingVisible` opt-out.
- `times: 1` option.
- `removeLocatorHandler(locator, handler)` method.
- Passing `locator` as first argument to `handler`.

Fixes #30471. Fixes #30424. Fixes #29779.
This commit is contained in:
Dmitry Gozman 2024-04-24 15:19:12 -07:00 committed by GitHub
parent e2f7acea1e
commit 59689c9c97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 394 additions and 83 deletions

View File

@ -3155,6 +3155,7 @@ This method lets you set up a special function, called a handler, that activates
Things to keep in mind:
* When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as a part of your normal test flow, instead of using [`method: Page.addLocatorHandler`].
* Playwright checks for the overlay every time before executing or retrying an action that requires an [actionability check](../actionability.md), or before performing an auto-waiting assertion check. When overlay is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't perform any actions, the handler will not be triggered.
* After executing the handler, Playwright will ensure that overlay that triggered the handler is not visible anymore. You can opt-out of this behavior with [`option: allowStayingVisible`].
* The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. If your handler takes too long, it might cause timeouts.
* You can register multiple handlers. However, only a single handler will be running at a time. Make sure the actions within a handler don't depend on another handler.
@ -3284,13 +3285,13 @@ await page.GotoAsync("https://example.com");
await page.GetByRole("button", new() { Name = "Start here" }).ClickAsync();
```
An example with a custom callback on every actionability check. It uses a `<body>` locator that is always visible, so the handler is called before every actionability check:
An example with a custom callback on every actionability check. It uses a `<body>` locator that is always visible, so the handler is called before every actionability check. It is important to specify [`option: allowStayingVisible`], because the handler does not hide the `<body>` element.
```js
// Setup the handler.
await page.addLocatorHandler(page.locator('body'), async () => {
await page.evaluate(() => window.removeObstructionsForTestIfNeeded());
});
}, { allowStayingVisible: true });
// Write the test as usual.
await page.goto('https://example.com');
@ -3301,7 +3302,7 @@ await page.getByRole('button', { name: 'Start here' }).click();
// Setup the handler.
page.addLocatorHandler(page.locator("body")), () => {
page.evaluate("window.removeObstructionsForTestIfNeeded()");
});
}, new Page.AddLocatorHandlerOptions.setAllowStayingVisible(true));
// Write the test as usual.
page.goto("https://example.com");
@ -3312,7 +3313,7 @@ page.getByRole("button", Page.GetByRoleOptions().setName("Start here")).click();
# Setup the handler.
def handler():
page.evaluate("window.removeObstructionsForTestIfNeeded()")
page.add_locator_handler(page.locator("body"), handler)
page.add_locator_handler(page.locator("body"), handler, allow_staying_visible=True)
# Write the test as usual.
page.goto("https://example.com")
@ -3323,7 +3324,7 @@ page.get_by_role("button", name="Start here").click()
# Setup the handler.
def handler():
await page.evaluate("window.removeObstructionsForTestIfNeeded()")
await page.add_locator_handler(page.locator("body"), handler)
await page.add_locator_handler(page.locator("body"), handler, allow_staying_visible=True)
# Write the test as usual.
await page.goto("https://example.com")
@ -3334,13 +3335,45 @@ await page.get_by_role("button", name="Start here").click()
// Setup the handler.
await page.AddLocatorHandlerAsync(page.Locator("body"), async () => {
await page.EvaluateAsync("window.removeObstructionsForTestIfNeeded()");
});
}, new() { AllowStayingVisible = true });
// Write the test as usual.
await page.GotoAsync("https://example.com");
await page.GetByRole("button", new() { Name = "Start here" }).ClickAsync();
```
Handler takes the original locator as an argument. You can also automatically remove the handler after a number of invocations by setting [`option: times`]:
```js
await page.addLocatorHandler(page.getByLabel('Close'), async locator => {
await locator.click();
}, { times: 1 });
```
```java
page.addLocatorHandler(page.getByLabel("Close"), locator => {
locator.click();
}, new Page.AddLocatorHandlerOptions().setTimes(1));
```
```python sync
def handler(locator):
locator.click()
page.add_locator_handler(page.get_by_label("Close"), handler, times=1)
```
```python async
def handler(locator):
await locator.click()
await page.add_locator_handler(page.get_by_label("Close"), handler, times=1)
```
```csharp
await page.AddLocatorHandlerAsync(page.GetByText("Sign up to the newsletter"), async locator => {
await locator.ClickAsync();
}, new() { Times = 1 });
```
### param: Page.addLocatorHandler.locator
* since: v1.42
- `locator` <[Locator]>
@ -3350,24 +3383,67 @@ Locator that triggers the handler.
### param: Page.addLocatorHandler.handler
* langs: js, python
* since: v1.42
- `handler` <[function]>
- `handler` <[function]\([Locator]\): [Promise<any>]>
Function that should be run once [`param: locator`] appears. This function should get rid of the element that blocks actions like click.
### param: Page.addLocatorHandler.handler
* langs: csharp
* since: v1.42
- `handler` <[function](): [Promise<any>]>
- `handler` <[function]\([Locator]\)>
Function that should be run once [`param: locator`] appears. This function should get rid of the element that blocks actions like click.
### param: Page.addLocatorHandler.handler
* langs: java
* since: v1.42
- `handler` <[Runnable]>
- `handler` <[function]\([Locator]\)>
Function that should be run once [`param: locator`] appears. This function should get rid of the element that blocks actions like click.
### option: Page.addLocatorHandler.times
* since: v1.44
- `times` <[int]>
Specifies the maximum number of times this handler should be called. Unlimited by default.
### option: Page.addLocatorHandler.allowStayingVisible
* since: v1.44
- `allowStayingVisible` <[boolean]>
By default, after calling the handler Playwright will wait until the overlay becomes hidden, and only then Playwright will continue with the action/assertion that triggered the handler. This option allows to opt-out of this behavior, so that overlay can stay visible after the handler has run.
## async method: Page.removeLocatorHandler
* since: v1.44
:::warning[Experimental]
This method is experimental and its behavior may change in the upcoming releases.
:::
Removes locator handler added by [`method: Page.addLocatorHandler`].
### param: Page.removeLocatorHandler.locator
* since: v1.44
- `locator` <[Locator]>
Locator passed to [`method: Page.addLocatorHandler`].
### param: Page.removeLocatorHandler.handler
* langs: js, python
* since: v1.44
- `handler` <[function]\([Locator]\): [Promise<any>]>
Handler passed to [`method: Page.addLocatorHandler`].
### param: Page.addLocatorHandler.handler
* langs: csharp, java
* since: v1.44
- `handler` <[function]\([Locator]\)>
Handler passed to [`method: Page.addLocatorHandler`].
## async method: Page.reload
* since: v1.8
- returns: <[null]|[Response]>

View File

@ -80,6 +80,10 @@ export class Locator implements api.Locator {
});
}
_equals(locator: Locator) {
return this._frame === locator._frame && this._selector === locator._selector;
}
page() {
return this._frame.page();
}

View File

@ -96,7 +96,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
_closeWasCalled: boolean = false;
private _harRouters: HarRouter[] = [];
private _locatorHandlers = new Map<number, Function>();
private _locatorHandlers = new Map<number, { locator: Locator, handler: (locator: Locator) => any, times: number | undefined }>();
static from(page: channels.PageChannel): Page {
return (page as any)._object;
@ -362,19 +362,36 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return Response.fromNullable((await this._channel.reload({ ...options, waitUntil })).response);
}
async addLocatorHandler(locator: Locator, handler: Function): Promise<void> {
async addLocatorHandler(locator: Locator, handler: (locator: Locator) => any, options: { times?: number, allowStayingVisible?: boolean } = {}): Promise<void> {
if (locator._frame !== this._mainFrame)
throw new Error(`Locator must belong to the main frame of this page`);
const { uid } = await this._channel.registerLocatorHandler({ selector: locator._selector });
this._locatorHandlers.set(uid, handler);
if (options.times === 0)
return;
const { uid } = await this._channel.registerLocatorHandler({ selector: locator._selector, allowStayingVisible: options.allowStayingVisible });
this._locatorHandlers.set(uid, { locator, handler, times: options.times });
}
private async _onLocatorHandlerTriggered(uid: number) {
let remove = false;
try {
const handler = this._locatorHandlers.get(uid);
await handler?.();
if (handler && handler.times !== 0) {
if (handler.times !== undefined)
handler.times--;
await handler.handler(handler.locator);
}
remove = handler?.times === 0;
} finally {
this._wrapApiCall(() => this._channel.resolveLocatorHandlerNoReply({ uid }), true).catch(() => {});
this._wrapApiCall(() => this._channel.resolveLocatorHandlerNoReply({ uid, remove }), true).catch(() => {});
}
}
async removeLocatorHandler(locator: Locator, handler: (locator: Locator) => any): Promise<void> {
for (const [uid, data] of this._locatorHandlers) {
if (data.locator._equals(locator) && data.handler === handler) {
this._locatorHandlers.delete(uid);
await this._channel.unregisterLocatorHandlerNoReply({ uid }).catch(() => {});
}
}
}

View File

@ -1046,14 +1046,20 @@ scheme.PageGoForwardResult = tObject({
});
scheme.PageRegisterLocatorHandlerParams = tObject({
selector: tString,
allowStayingVisible: tOptional(tBoolean),
});
scheme.PageRegisterLocatorHandlerResult = tObject({
uid: tNumber,
});
scheme.PageResolveLocatorHandlerNoReplyParams = tObject({
uid: tNumber,
remove: tOptional(tBoolean),
});
scheme.PageResolveLocatorHandlerNoReplyResult = tOptional(tObject({}));
scheme.PageUnregisterLocatorHandlerNoReplyParams = tObject({
uid: tNumber,
});
scheme.PageUnregisterLocatorHandlerNoReplyResult = tOptional(tObject({}));
scheme.PageReloadParams = tObject({
timeout: tOptional(tNumber),
waitUntil: tOptional(tType('LifecycleEvent')),

View File

@ -138,12 +138,16 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
}
async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise<channels.PageRegisterLocatorHandlerResult> {
const uid = this._page.registerLocatorHandler(params.selector);
const uid = this._page.registerLocatorHandler(params.selector, params.allowStayingVisible);
return { uid };
}
async resolveLocatorHandlerNoReply(params: channels.PageResolveLocatorHandlerNoReplyParams, metadata: CallMetadata): Promise<void> {
this._page.resolveLocatorHandler(params.uid);
this._page.resolveLocatorHandler(params.uid, params.remove);
}
async unregisterLocatorHandlerNoReply(params: channels.PageUnregisterLocatorHandlerNoReplyParams, metadata: CallMetadata): Promise<void> {
this._page.unregisterLocatorHandler(params.uid);
}
async emulateMedia(params: channels.PageEmulateMediaParams, metadata: CallMetadata): Promise<void> {

View File

@ -773,56 +773,61 @@ export class Frame extends SdkObject {
throw new Error(`state: expected one of (attached|detached|visible|hidden)`);
return controller.run(async progress => {
progress.log(`waiting for ${this._asLocator(selector)}${state === 'attached' ? '' : ' to be ' + state}`);
const promise = this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => {
const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope);
progress.throwIfAborted();
if (!resolved) {
if (state === 'hidden' || state === 'detached')
return null;
return continuePolling;
}
const result = await resolved.injected.evaluateHandle((injected, { info, root }) => {
const elements = injected.querySelectorAll(info.parsed, root || document);
const element: Element | undefined = elements[0];
const visible = element ? injected.isVisible(element) : false;
let log = '';
if (elements.length > 1) {
if (info.strict)
throw injected.strictModeViolationError(info.parsed, elements);
log = ` locator resolved to ${elements.length} elements. Proceeding with the first one: ${injected.previewNode(elements[0])}`;
} else if (element) {
log = ` locator resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`;
}
return { log, element, visible, attached: !!element };
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
const { log, visible, attached } = await result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached }));
if (log)
progress.log(log);
const success = { attached, detached: !attached, visible, hidden: !visible }[state];
if (!success) {
result.dispose();
return continuePolling;
}
if (options.omitReturnValue) {
result.dispose();
return null;
}
const element = state === 'attached' || state === 'visible' ? await result.evaluateHandle(r => r.element) : null;
result.dispose();
if (!element)
return null;
if ((options as any).__testHookBeforeAdoptNode)
await (options as any).__testHookBeforeAdoptNode();
try {
return await element._adoptTo(await resolved.frame._mainContext());
} catch (e) {
return continuePolling;
}
});
return scope ? scope._context._raceAgainstContextDestroyed(promise) : promise;
return await this.waitForSelectorInternal(progress, selector, options, scope);
}, this._page._timeoutSettings.timeout(options));
}
async waitForSelectorInternal(progress: Progress, selector: string, options: types.WaitForElementOptions, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
const { state = 'visible' } = options;
const promise = this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => {
const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope);
progress.throwIfAborted();
if (!resolved) {
if (state === 'hidden' || state === 'detached')
return null;
return continuePolling;
}
const result = await resolved.injected.evaluateHandle((injected, { info, root }) => {
const elements = injected.querySelectorAll(info.parsed, root || document);
const element: Element | undefined = elements[0];
const visible = element ? injected.isVisible(element) : false;
let log = '';
if (elements.length > 1) {
if (info.strict)
throw injected.strictModeViolationError(info.parsed, elements);
log = ` locator resolved to ${elements.length} elements. Proceeding with the first one: ${injected.previewNode(elements[0])}`;
} else if (element) {
log = ` locator resolved to ${visible ? 'visible' : 'hidden'} ${injected.previewNode(element)}`;
}
return { log, element, visible, attached: !!element };
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
const { log, visible, attached } = await result.evaluate(r => ({ log: r.log, visible: r.visible, attached: r.attached }));
if (log)
progress.log(log);
const success = { attached, detached: !attached, visible, hidden: !visible }[state];
if (!success) {
result.dispose();
return continuePolling;
}
if (options.omitReturnValue) {
result.dispose();
return null;
}
const element = state === 'attached' || state === 'visible' ? await result.evaluateHandle(r => r.element) : null;
result.dispose();
if (!element)
return null;
if ((options as any).__testHookBeforeAdoptNode)
await (options as any).__testHookBeforeAdoptNode();
try {
return await element._adoptTo(await resolved.frame._mainContext());
} catch (e) {
return continuePolling;
}
});
return scope ? scope._context._raceAgainstContextDestroyed(promise) : promise;
}
async dispatchEvent(metadata: CallMetadata, selector: string, type: string, eventInit: Object = {}, options: types.QueryOnSelectorOptions = {}, scope?: dom.ElementHandle): Promise<void> {
await this._callOnElementOnceMatches(metadata, selector, (injectedScript, element, data) => {
injectedScript.dispatchEvent(element, data.type, data.eventInit);

View File

@ -168,7 +168,7 @@ export class Page extends SdkObject {
_video: Artifact | null = null;
_opener: Page | undefined;
private _isServerSideOnly = false;
private _locatorHandlers = new Map<number, { selector: string, resolved?: ManualPromise<void> }>();
private _locatorHandlers = new Map<number, { selector: string, allowStayingVisible?: boolean, resolved?: ManualPromise<void> }>();
private _lastLocatorHandlerUid = 0;
private _locatorHandlerRunningCounter = 0;
@ -432,20 +432,26 @@ export class Page extends SdkObject {
}), this._timeoutSettings.navigationTimeout(options));
}
registerLocatorHandler(selector: string) {
registerLocatorHandler(selector: string, allowStayingVisible: boolean | undefined) {
const uid = ++this._lastLocatorHandlerUid;
this._locatorHandlers.set(uid, { selector });
this._locatorHandlers.set(uid, { selector, allowStayingVisible });
return uid;
}
resolveLocatorHandler(uid: number) {
resolveLocatorHandler(uid: number, remove: boolean | undefined) {
const handler = this._locatorHandlers.get(uid);
if (remove)
this._locatorHandlers.delete(uid);
if (handler) {
handler.resolved?.resolve();
handler.resolved = undefined;
}
}
unregisterLocatorHandler(uid: number) {
this._locatorHandlers.delete(uid);
}
async performLocatorHandlersCheckpoint(progress: Progress) {
// Do not run locator handlers from inside locator handler callbacks to avoid deadlocks.
if (this._locatorHandlerRunningCounter)
@ -460,7 +466,12 @@ export class Page extends SdkObject {
if (handler.resolved) {
++this._locatorHandlerRunningCounter;
progress.log(` found ${asLocator(this.attribution.playwright.options.sdkLanguage, handler.selector)}, intercepting action to run the handler`);
await this.openScope.race(handler.resolved).finally(() => --this._locatorHandlerRunningCounter);
const promise = handler.resolved.then(async () => {
progress.throwIfAborted();
if (!handler.allowStayingVisible)
await this.mainFrame().waitForSelectorInternal(progress, handler.selector, { state: 'hidden' });
});
await this.openScope.race(promise).finally(() => --this._locatorHandlerRunningCounter);
// Avoid side-effects after long-running operation.
progress.throwIfAborted();
progress.log(` interception handler has finished, continuing`);

View File

@ -1802,12 +1802,14 @@ export interface Page {
* Things to keep in mind:
* - When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as
* a part of your normal test flow, instead of using
* [page.addLocatorHandler(locator, handler)](https://playwright.dev/docs/api/class-page#page-add-locator-handler).
* [page.addLocatorHandler(locator, handler[, options])](https://playwright.dev/docs/api/class-page#page-add-locator-handler).
* - Playwright checks for the overlay every time before executing or retrying an action that requires an
* [actionability check](https://playwright.dev/docs/actionability), or before performing an auto-waiting assertion check. When overlay
* is visible, Playwright calls the handler first, and then proceeds with the action/assertion. Note that the
* handler is only called when you perform an action/assertion - if the overlay becomes visible but you don't
* perform any actions, the handler will not be triggered.
* - After executing the handler, Playwright will ensure that overlay that triggered the handler is not visible
* anymore. You can opt-out of this behavior with `allowStayingVisible`.
* - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler.
* If your handler takes too long, it might cause timeouts.
* - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the
@ -1857,24 +1859,47 @@ export interface Page {
* ```
*
* An example with a custom callback on every actionability check. It uses a `<body>` locator that is always visible,
* so the handler is called before every actionability check:
* so the handler is called before every actionability check. It is important to specify `allowStayingVisible`,
* because the handler does not hide the `<body>` element.
*
* ```js
* // Setup the handler.
* await page.addLocatorHandler(page.locator('body'), async () => {
* await page.evaluate(() => window.removeObstructionsForTestIfNeeded());
* });
* }, { allowStayingVisible: true });
*
* // Write the test as usual.
* await page.goto('https://example.com');
* await page.getByRole('button', { name: 'Start here' }).click();
* ```
*
* Handler takes the original locator as an argument. You can also automatically remove the handler after a number of
* invocations by setting `times`:
*
* ```js
* await page.addLocatorHandler(page.getByLabel('Close'), async locator => {
* await locator.click();
* }, { times: 1 });
* ```
*
* @param locator Locator that triggers the handler.
* @param handler Function that should be run once `locator` appears. This function should get rid of the element that blocks actions
* like click.
* @param options
*/
addLocatorHandler(locator: Locator, handler: Function): Promise<void>;
addLocatorHandler(locator: Locator, handler: ((locator: Locator) => Promise<any>), options?: {
/**
* By default, after calling the handler Playwright will wait until the overlay becomes hidden, and only then
* Playwright will continue with the action/assertion that triggered the handler. This option allows to opt-out of
* this behavior, so that overlay can stay visible after the handler has run.
*/
allowStayingVisible?: boolean;
/**
* Specifies the maximum number of times this handler should be called. Unlimited by default.
*/
times?: number;
}): Promise<void>;
/**
* Adds a `<script>` tag into the page with the desired url or content. Returns the added tag when the script's onload
@ -3654,6 +3679,18 @@ export interface Page {
waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit";
}): Promise<null|Response>;
/**
* **NOTE** This method is experimental and its behavior may change in the upcoming releases.
*
* Removes locator handler added by
* [page.addLocatorHandler(locator, handler[, options])](https://playwright.dev/docs/api/class-page#page-add-locator-handler).
* @param locator Locator passed to
* [page.addLocatorHandler(locator, handler[, options])](https://playwright.dev/docs/api/class-page#page-add-locator-handler).
* @param handler Handler passed to
* [page.addLocatorHandler(locator, handler[, options])](https://playwright.dev/docs/api/class-page#page-add-locator-handler).
*/
removeLocatorHandler(locator: Locator, handler: ((locator: Locator) => Promise<any>)): Promise<void>;
/**
* Routing provides the capability to modify network requests that are made by a page.
*

View File

@ -1790,6 +1790,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel {
goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise<PageGoForwardResult>;
registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageRegisterLocatorHandlerResult>;
resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise<PageResolveLocatorHandlerNoReplyResult>;
unregisterLocatorHandlerNoReply(params: PageUnregisterLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise<PageUnregisterLocatorHandlerNoReplyResult>;
reload(params: PageReloadParams, metadata?: CallMetadata): Promise<PageReloadResult>;
expectScreenshot(params: PageExpectScreenshotParams, metadata?: CallMetadata): Promise<PageExpectScreenshotResult>;
screenshot(params: PageScreenshotParams, metadata?: CallMetadata): Promise<PageScreenshotResult>;
@ -1926,20 +1927,29 @@ export type PageGoForwardResult = {
};
export type PageRegisterLocatorHandlerParams = {
selector: string,
allowStayingVisible?: boolean,
};
export type PageRegisterLocatorHandlerOptions = {
allowStayingVisible?: boolean,
};
export type PageRegisterLocatorHandlerResult = {
uid: number,
};
export type PageResolveLocatorHandlerNoReplyParams = {
uid: number,
remove?: boolean,
};
export type PageResolveLocatorHandlerNoReplyOptions = {
remove?: boolean,
};
export type PageResolveLocatorHandlerNoReplyResult = void;
export type PageUnregisterLocatorHandlerNoReplyParams = {
uid: number,
};
export type PageUnregisterLocatorHandlerNoReplyOptions = {
};
export type PageUnregisterLocatorHandlerNoReplyResult = void;
export type PageReloadParams = {
timeout?: number,
waitUntil?: LifecycleEvent,

View File

@ -1350,10 +1350,16 @@ Page:
registerLocatorHandler:
parameters:
selector: string
allowStayingVisible: boolean?
returns:
uid: number
resolveLocatorHandlerNoReply:
parameters:
uid: number
remove: boolean?
unregisterLocatorHandlerNoReply:
parameters:
uid: number

View File

@ -50,9 +50,16 @@
}, false);
close.addEventListener('click', () => {
interstitial.classList.remove('visible');
target.classList.remove('hidden');
target.classList.remove('removed');
const closeInterstitial = () => {
interstitial.classList.remove('visible');
target.classList.remove('hidden');
target.classList.remove('removed');
};
if (interstitial.classList.contains('timeout'))
setTimeout(closeInterstitial, 3000);
else
closeInterstitial();
});
let timesToShow = 0;
@ -65,9 +72,11 @@
if (!timesToShow && event !== 'none')
target.removeEventListener(event, listener, capture === 'capture');
};
if (event === 'hide') {
if (event === 'hide' || event === 'timeout') {
target.classList.add('hidden');
listener();
if (event === 'timeout')
interstitial.classList.add('timeout');
} else if (event === 'remove') {
target.classList.add('removed');
listener();

View File

@ -22,7 +22,9 @@ test('should work', async ({ page, server }) => {
let beforeCount = 0;
let afterCount = 0;
await page.addLocatorHandler(page.getByText('This interstitial covers the button'), async () => {
const originalLocator = page.getByText('This interstitial covers the button');
await page.addLocatorHandler(originalLocator, async locatorArgument => {
expect(locatorArgument).toBe(originalLocator);
++beforeCount;
await page.locator('#close').click();
++afterCount;
@ -64,7 +66,7 @@ test('should work with a custom check', async ({ page, server }) => {
await page.addLocatorHandler(page.locator('body'), async () => {
if (await page.getByText('This interstitial covers the button').isVisible())
await page.locator('#close').click();
});
}, { allowStayingVisible: true });
for (const args of [
['mouseover', 2],
@ -204,3 +206,129 @@ test('should work with toHaveScreenshot', async ({ page, server, isAndroid }) =>
await expect(page).toHaveScreenshot('screenshot-grid.png');
});
test('should work when owner frame detaches', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.src = 'data:text/html,<body>hello from iframe</body>';
document.body.append(iframe);
const target = document.createElement('button');
target.textContent = 'Click me';
target.id = 'target';
target.addEventListener('click', () => (window as any)._clicked = true);
document.body.appendChild(target);
const closeButton = document.createElement('button');
closeButton.textContent = 'close';
closeButton.id = 'close';
closeButton.addEventListener('click', () => iframe.remove());
document.body.appendChild(closeButton);
});
await page.addLocatorHandler(page.frameLocator('iframe').locator('body'), async () => {
await page.locator('#close').click();
});
await page.locator('#target').click();
expect(await page.$('iframe')).toBe(null);
expect(await page.evaluate('window._clicked')).toBe(true);
});
test('should work with times: option', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/handle-locator.html');
let called = 0;
await page.addLocatorHandler(page.locator('body'), async () => {
++called;
}, { allowStayingVisible: true, times: 2 });
await page.locator('#aside').hover();
await page.evaluate(() => {
(window as any).clicked = 0;
(window as any).setupAnnoyingInterstitial('mouseover', 4);
});
const error = await page.locator('#target').click({ timeout: 3000 }).catch(e => e);
expect(called).toBe(2);
expect(await page.evaluate('window.clicked')).toBe(0);
await expect(page.locator('#interstitial')).toBeVisible();
expect(error.message).toContain('Timeout 3000ms exceeded');
expect(error.message).toContain(`<div>This interstitial covers the button</div> from <div class="visible" id="interstitial">…</div> subtree intercepts pointer events`);
});
test('should wait for hidden by default', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/handle-locator.html');
let called = 0;
await page.addLocatorHandler(page.getByRole('button', { name: 'close' }), async button => {
called++;
await button.click();
});
await page.locator('#aside').hover();
await page.evaluate(() => {
(window as any).clicked = 0;
(window as any).setupAnnoyingInterstitial('timeout', 1);
});
await page.locator('#target').click();
expect(await page.evaluate('window.clicked')).toBe(1);
await expect(page.locator('#interstitial')).not.toBeVisible();
expect(called).toBe(1);
});
test('should work with allowStayingVisible', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/handle-locator.html');
let called = 0;
await page.addLocatorHandler(page.getByRole('button', { name: 'close' }), async button => {
called++;
if (called === 1)
await button.click();
else
await page.locator('#interstitial').waitFor({ state: 'hidden' });
}, { allowStayingVisible: true });
await page.locator('#aside').hover();
await page.evaluate(() => {
(window as any).clicked = 0;
(window as any).setupAnnoyingInterstitial('timeout', 1);
});
await page.locator('#target').click();
expect(await page.evaluate('window.clicked')).toBe(1);
await expect(page.locator('#interstitial')).not.toBeVisible();
expect(called).toBe(2);
});
test('should removeLocatorHandler', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/handle-locator.html');
let called = 0;
const handler = async locator => {
++called;
await locator.click();
};
await page.addLocatorHandler(page.getByRole('button', { name: 'close' }), handler);
await page.evaluate(() => {
(window as any).clicked = 0;
(window as any).setupAnnoyingInterstitial('hide', 1);
});
await page.locator('#target').click();
expect(called).toBe(1);
expect(await page.evaluate('window.clicked')).toBe(1);
await expect(page.locator('#interstitial')).not.toBeVisible();
await page.evaluate(() => {
(window as any).clicked = 0;
(window as any).setupAnnoyingInterstitial('hide', 1);
});
await page.removeLocatorHandler(page.getByRole('button', { name: 'close' }), handler);
const error = await page.locator('#target').click({ timeout: 3000 }).catch(e => e);
expect(called).toBe(1);
expect(await page.evaluate('window.clicked')).toBe(0);
await expect(page.locator('#interstitial')).toBeVisible();
expect(error.message).toContain('Timeout 3000ms exceeded');
});

View File

@ -154,8 +154,6 @@ playwright.chromium.launch().then(async browser => {
return 'something random for no reason';
});
await page.addLocatorHandler(page.locator(''), () => {});
await page.addLocatorHandler(page.locator(''), () => 42);
await page.addLocatorHandler(page.locator(''), async () => { });
await page.addLocatorHandler(page.locator(''), async () => 42);
await page.addLocatorHandler(page.locator(''), () => Promise.resolve(42));