diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md
index 796a3cecc7..7802ed0007 100644
--- a/docs/src/api/class-page.md
+++ b/docs/src/api/class-page.md
@@ -3136,10 +3136,22 @@ return value resolves to `[]`.
Registers a handler for an element that might block certain actions like click. The handler should get rid of the blocking element so that an action may proceed. This is useful for nondeterministic interstitial pages or dialogs, like a cookie consent dialog.
-The handler will be executed before [actionability checks](../actionability.md) for each action, and also before each attempt of the [web assertions](../test-assertions.md). When no actions or assertions are executed, the handler will not be run at all, even if the interstitial element appears on the page.
+The handler will be executed before the [actionability checks](../actionability.md) for each action, as well as before each probe of the [web assertions](../test-assertions.md). When no actions are executed and no assertions are probed, the handler does not run at all, even if the given locator appears on the page. Actions that pass the `force` option do not trigger the handler.
Note that execution time of the handler counts towards the timeout of the action/assertion that executed the handler.
+You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a handler must not require another handler to run.
+
+:::warning
+Running the interceptor will alter your page state mid-test. For example it will change the currently focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained and do not rely on the focus and mouse state.
+
+
+For example, consider a test that calls [`method: Locator.focus`] followed by [`method: Keyboard.press`]. If your handler clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen on the unexpected element. Use [`method: Locator.press`] instead to avoid this problem.
+
+
+Another example is a series of mouse actions, where [`method: Mouse.move`] is followed by [`method: Mouse.down`]. Again, when the handler runs between these two actions, the mouse position will be wrong during the mouse down. Prefer methods like [`method: Locator.click`] that are self-contained.
+:::
+
**Usage**
An example that closes a cookie dialog when it appears:
diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts
index 8ef39c4994..aa9b5417ca 100644
--- a/packages/playwright-core/src/client/page.ts
+++ b/packages/playwright-core/src/client/page.ts
@@ -374,7 +374,7 @@ export class Page extends ChannelOwner implements api.Page
const handler = this._locatorHandlers.get(uid);
await handler?.();
} finally {
- this._channel.resolveLocatorHandlerNoReply({ uid }).catch(() => {});
+ this._wrapApiCall(() => this._channel.resolveLocatorHandlerNoReply({ uid }), true).catch(() => {});
}
}
diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts
index d2188aab5a..ef7b152b88 100644
--- a/packages/playwright-core/src/server/dom.ts
+++ b/packages/playwright-core/src/server/dom.ts
@@ -306,7 +306,7 @@ export class ElementHandle extends js.JSHandle {
} else {
progress.log(`attempting ${actionName} action${options.trial ? ' (trial run)' : ''}`);
}
- if (!options.skipLocatorHandlersCheckpoint)
+ if (!options.skipLocatorHandlersCheckpoint && !options.force)
await this._frame._page.performLocatorHandlersCheckpoint(progress);
const result = await action(retry);
++retry;
diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts
index e99ed29177..dab9a1ebcc 100644
--- a/packages/playwright-core/src/server/frames.ts
+++ b/packages/playwright-core/src/server/frames.ts
@@ -1146,21 +1146,21 @@ export class Frame extends SdkObject {
async click(metadata: CallMetadata, selector: string, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._click(progress, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._click(progress, options)));
}, this._page._timeoutSettings.timeout(options));
}
async dblclick(metadata: CallMetadata, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._dblclick(progress, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._dblclick(progress, options)));
}, this._page._timeoutSettings.timeout(options));
}
async dragAndDrop(metadata: CallMetadata, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this);
await controller.run(async progress => {
- dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, true /* performLocatorHandlersCheckpoint */, async handle => {
+ dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, !options.force /* performLocatorHandlersCheckpoint */, async handle => {
return handle._retryPointerAction(progress, 'move and down', false, async point => {
await this._page.mouse.move(point.x, point.y);
await this._page.mouse.down();
@@ -1189,14 +1189,14 @@ export class Frame extends SdkObject {
throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.');
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._tap(progress, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._tap(progress, options)));
}, this._page._timeoutSettings.timeout(options));
}
async fill(metadata: CallMetadata, selector: string, value: string, options: types.NavigatingActionWaitOptions & { force?: boolean }) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._fill(progress, value, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._fill(progress, value, options)));
}, this._page._timeoutSettings.timeout(options));
}
@@ -1317,14 +1317,14 @@ export class Frame extends SdkObject {
async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._hover(progress, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._hover(progress, options)));
}, this._page._timeoutSettings.timeout(options));
}
async selectOption(metadata: CallMetadata, selector: string, elements: dom.ElementHandle[], values: types.SelectOption[], options: types.NavigatingActionWaitOptions & types.ForceOptions = {}): Promise {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._selectOption(progress, elements, values, options));
+ return await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._selectOption(progress, elements, values, options));
}, this._page._timeoutSettings.timeout(options));
}
@@ -1353,14 +1353,14 @@ export class Frame extends SdkObject {
async check(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._setChecked(progress, true, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._setChecked(progress, true, options)));
}, this._page._timeoutSettings.timeout(options));
}
async uncheck(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this);
return controller.run(async progress => {
- return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._setChecked(progress, false, options)));
+ return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, !options.force /* performLocatorHandlersCheckpoint */, handle => handle._setChecked(progress, false, options)));
}, this._page._timeoutSettings.timeout(options));
}
diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts
index 5eab7d9333..359fed8360 100644
--- a/packages/playwright-core/src/server/page.ts
+++ b/packages/playwright-core/src/server/page.ts
@@ -44,6 +44,7 @@ import { isInvalidSelectorError } from '../utils/isomorphic/selectorParser';
import { parseEvaluationResultValue, source } from './isomorphic/utilityScriptSerializers';
import type { SerializedValue } from './isomorphic/utilityScriptSerializers';
import { TargetClosedError } from './errors';
+import { asLocator } from '../utils/isomorphic/locatorGenerators';
export interface PageDelegate {
readonly rawMouse: input.RawMouse;
@@ -458,9 +459,11 @@ 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);
// 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 0202372eab..8acbfad7bb 100644
--- a/packages/playwright-core/types/types.d.ts
+++ b/packages/playwright-core/types/types.d.ts
@@ -2928,13 +2928,31 @@ export interface Page {
* blocking element so that an action may proceed. This is useful for nondeterministic interstitial pages or dialogs,
* like a cookie consent dialog.
*
- * The handler will be executed before [actionability checks](https://playwright.dev/docs/actionability) for each action, and also before
- * each attempt of the [web assertions](https://playwright.dev/docs/test-assertions). When no actions or assertions are executed, the
- * handler will not be run at all, even if the interstitial element appears on the page.
+ * The handler will be executed before the [actionability checks](https://playwright.dev/docs/actionability) for each action, as well as
+ * before each probe of the [web assertions](https://playwright.dev/docs/test-assertions). When no actions are executed and no assertions
+ * are probed, the handler does not run at all, even if the given locator appears on the page. Actions that pass the
+ * `force` option do not trigger the handler.
*
* Note that execution time of the handler counts towards the timeout of the action/assertion that executed the
* handler.
*
+ * You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a
+ * handler must not require another handler to run.
+ *
+ * **NOTE** Running the interceptor will alter your page state mid-test. For example it will change the currently
+ * focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained
+ * and do not rely on the focus and mouse state.
For example, consider a test that calls
+ * [locator.focus([options])](https://playwright.dev/docs/api/class-locator#locator-focus) followed by
+ * [keyboard.press(key[, options])](https://playwright.dev/docs/api/class-keyboard#keyboard-press). If your handler
+ * clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen
+ * on the unexpected element. Use
+ * [locator.press(key[, options])](https://playwright.dev/docs/api/class-locator#locator-press) instead to avoid this
+ * problem.
Another example is a series of mouse actions, where
+ * [mouse.move(x, y[, options])](https://playwright.dev/docs/api/class-mouse#mouse-move) is followed by
+ * [mouse.down([options])](https://playwright.dev/docs/api/class-mouse#mouse-down). Again, when the handler runs
+ * between these two actions, the mouse position will be wrong during the mouse down. Prefer methods like
+ * [locator.click([options])](https://playwright.dev/docs/api/class-locator#locator-click) that are self-contained.
+ *
* **Usage**
*
* An example that closes a cookie dialog when it appears:
diff --git a/tests/assets/input/handle-locator.html b/tests/assets/input/handle-locator.html
index abefafd7a2..865fb53645 100644
--- a/tests/assets/input/handle-locator.html
+++ b/tests/assets/input/handle-locator.html
@@ -14,6 +14,9 @@
#target.hidden {
visibility: hidden;
}
+ #target:hover {
+ background: yellow;
+ }
#interstitial {
position: absolute;
top: 0;
diff --git a/tests/page/page-handle-locator.spec.ts b/tests/page/page-handle-locator.spec.ts
index e7c829218f..da57a2fc86 100644
--- a/tests/page/page-handle-locator.spec.ts
+++ b/tests/page/page-handle-locator.spec.ts
@@ -85,6 +85,38 @@ test('should work with a custom check', async ({ page, server }) => {
}
});
+test('should work with locator.hover()', async ({ page, server }) => {
+ await page.goto(server.PREFIX + '/input/handle-locator.html');
+
+ await page.handleLocator(page.getByText('This interstitial covers the button'), async () => {
+ await page.locator('#close').click();
+ });
+
+ await page.locator('#aside').hover();
+ await page.evaluate(() => {
+ (window as any).setupAnnoyingInterstitial('pointerover', 1, 'capture');
+ });
+ await page.locator('#target').hover();
+ await expect(page.locator('#interstitial')).not.toBeVisible();
+ expect(await page.$eval('#target', e => window.getComputedStyle(e).backgroundColor)).toBe('rgb(255, 255, 0)');
+});
+
+test('should not work with force:true', async ({ page, server }) => {
+ await page.goto(server.PREFIX + '/input/handle-locator.html');
+
+ await page.handleLocator(page.getByText('This interstitial covers the button'), async () => {
+ await page.locator('#close').click();
+ });
+
+ await page.locator('#aside').hover();
+ await page.evaluate(() => {
+ (window as any).setupAnnoyingInterstitial('none', 1);
+ });
+ await page.locator('#target').click({ force: true, timeout: 2000 });
+ expect(await page.locator('#interstitial').isVisible()).toBe(true);
+ expect(await page.evaluate('window.clicked')).toBe(undefined);
+});
+
test('should throw when page closes', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/handle-locator.html');