diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 3934270f07ecd..39610ae84dc39 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -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 `` 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 `` 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 `` 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]> 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]> +- `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]> + +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]> diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 8fd2d8e088de7..b6058e0abb825 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -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(); } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 8fd1394a6f717..254d4fbf6e8be 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -96,7 +96,7 @@ export class Page extends ChannelOwner implements api.Page _closeWasCalled: boolean = false; private _harRouters: HarRouter[] = []; - private _locatorHandlers = new Map(); + private _locatorHandlers = new Map any, times: number | undefined }>(); static from(page: channels.PageChannel): Page { return (page as any)._object; @@ -362,19 +362,36 @@ export class Page extends ChannelOwner implements api.Page return Response.fromNullable((await this._channel.reload({ ...options, waitUntil })).response); } - async addLocatorHandler(locator: Locator, handler: Function): Promise { + async addLocatorHandler(locator: Locator, handler: (locator: Locator) => any, options: { times?: number, allowStayingVisible?: boolean } = {}): Promise { 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 { + 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(() => {}); + } } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2ec820711ad45..d060718c50855 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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')), diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index fb2e1f0468352..d873d8d06a846 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -138,12 +138,16 @@ export class PageDispatcher extends Dispatcher { - 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 { - this._page.resolveLocatorHandler(params.uid); + this._page.resolveLocatorHandler(params.uid, params.remove); + } + + async unregisterLocatorHandlerNoReply(params: channels.PageUnregisterLocatorHandlerNoReplyParams, metadata: CallMetadata): Promise { + this._page.unregisterLocatorHandler(params.uid); } async emulateMedia(params: channels.PageEmulateMediaParams, metadata: CallMetadata): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 4a49231b3bf5d..73aa1f4dae9cd 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -773,54 +773,59 @@ 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 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 | 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)}`; } - const element = state === 'attached' || state === 'visible' ? await result.evaluateHandle(r => r.element) : null; + 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(); - 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; - }, this._page._timeoutSettings.timeout(options)); + 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 { diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 2c3fd8f6dd3d0..4bc8730b26ed5 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -168,7 +168,7 @@ export class Page extends SdkObject { _video: Artifact | null = null; _opener: Page | undefined; private _isServerSideOnly = false; - private _locatorHandlers = new Map }>(); + private _locatorHandlers = new Map }>(); 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`); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 23eee3fe17c27..fbc4105513dcd 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -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 `` 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 `` 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; + addLocatorHandler(locator: Locator, handler: ((locator: Locator) => Promise), 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; /** * Adds a `