diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 635f49ebba1ee..3b9a6d42e229b 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -3130,6 +3130,202 @@ return value resolves to `[]`. ### param: Page.querySelectorAll.selector = %%-query-selector-%% * since: v1.9 + +## async method: Page.handleLocator +* since: v1.42 + +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. + +Note that execution time of the handler counts towards the timeout of the action/assertion that executed the handler. + +**Usage** + +An example that closes a cookie dialog when it appears: + +```js +// Setup the handler. +await page.handleLocator(page.getByRole('button', { name: 'Accept all cookies' }), async () => { + await page.getByRole('button', { name: 'Reject all cookies' }).click(); +}); + +// Write the test as usual. +await page.goto('https://example.com'); +await page.getByRole('button', { name: 'Start here' }).click(); +``` + +```java +// Setup the handler. +page.handleLocator(page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Accept all cookies")), () => { + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Reject all cookies")).click(); +}); + +// Write the test as usual. +page.goto("https://example.com"); +page.getByRole("button", Page.GetByRoleOptions().setName("Start here")).click(); +``` + +```python sync +# Setup the handler. +def handler(): + page.get_by_role("button", name="Reject all cookies").click() +page.handle_locator(page.get_by_role("button", name="Accept all cookies"), handler) + +# Write the test as usual. +page.goto("https://example.com") +page.get_by_role("button", name="Start here").click() +``` + +```python async +# Setup the handler. +def handler(): + await page.get_by_role("button", name="Reject all cookies").click() +await page.handle_locator(page.get_by_role("button", name="Accept all cookies"), handler) + +# Write the test as usual. +await page.goto("https://example.com") +await page.get_by_role("button", name="Start here").click() +``` + +```csharp +// Setup the handler. +await page.HandleLocatorAsync(page.GetByRole(AriaRole.Button, new() { Name = "Accept all cookies" }), async () => { + await page.GetByRole(AriaRole.Button, new() { Name = "Reject all cookies" }).ClickAsync(); +}); + +// Write the test as usual. +await page.GotoAsync("https://example.com"); +await page.GetByRole("button", new() { Name = "Start here" }).ClickAsync(); +``` + +An example that skips the "Confirm your security details" page when it is shown: + +```js +// Setup the handler. +await page.handleLocator(page.getByText('Confirm your security details'), async () => { + await page.getByRole('button', 'Remind me later').click(); +}); + +// Write the test as usual. +await page.goto('https://example.com'); +await page.getByRole('button', { name: 'Start here' }).click(); +``` + +```java +// Setup the handler. +page.handleLocator(page.getByText("Confirm your security details")), () => { + page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Remind me later")).click(); +}); + +// Write the test as usual. +page.goto("https://example.com"); +page.getByRole("button", Page.GetByRoleOptions().setName("Start here")).click(); +``` + +```python sync +# Setup the handler. +def handler(): + page.get_by_role("button", name="Remind me later").click() +page.handle_locator(page.get_by_text("Confirm your security details"), handler) + +# Write the test as usual. +page.goto("https://example.com") +page.get_by_role("button", name="Start here").click() +``` + +```python async +# Setup the handler. +def handler(): + await page.get_by_role("button", name="Remind me later").click() +await page.handle_locator(page.get_by_text("Confirm your security details"), handler) + +# Write the test as usual. +await page.goto("https://example.com") +await page.get_by_role("button", name="Start here").click() +``` + +```csharp +// Setup the handler. +await page.HandleLocatorAsync(page.GetByText("Confirm your security details"), async () => { + await page.GetByRole(AriaRole.Button, new() { Name = "Remind me later" }).ClickAsync(); +}); + +// Write the test as usual. +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: + +```js +// Setup the handler. +await page.handleLocator(page.locator('body'), async () => { + await page.evaluate(() => window.removeObstructionsForTestIfNeeded()); +}); + +// Write the test as usual. +await page.goto('https://example.com'); +await page.getByRole('button', { name: 'Start here' }).click(); +``` + +```java +// Setup the handler. +page.handleLocator(page.locator("body")), () => { + page.evaluate("window.removeObstructionsForTestIfNeeded()"); +}); + +// Write the test as usual. +page.goto("https://example.com"); +page.getByRole("button", Page.GetByRoleOptions().setName("Start here")).click(); +``` + +```python sync +# Setup the handler. +def handler(): + page.evaluate("window.removeObstructionsForTestIfNeeded()") +page.handle_locator(page.locator("body"), handler) + +# Write the test as usual. +page.goto("https://example.com") +page.get_by_role("button", name="Start here").click() +``` + +```python async +# Setup the handler. +def handler(): + await page.evaluate("window.removeObstructionsForTestIfNeeded()") +await page.handle_locator(page.locator("body"), handler) + +# Write the test as usual. +await page.goto("https://example.com") +await page.get_by_role("button", name="Start here").click() +``` + +```csharp +// Setup the handler. +await page.HandleLocatorAsync(page.Locator("body"), async () => { + await page.EvaluateAsync("window.removeObstructionsForTestIfNeeded()"); +}); + +// Write the test as usual. +await page.GotoAsync("https://example.com"); +await page.GetByRole("button", new() { Name = "Start here" }).ClickAsync(); +``` + +### param: Page.handleLocator.locator +* since: v1.42 +- `locator` <[Locator]> + +Locator that triggers the handler. + +### param: Page.handleLocator.handler +* since: v1.42 +- `handler` <[function]> + +Function that should be run once [`param: locator`] appears. This function should get rid of the element that blocks actions like click. + + ## async method: Page.reload * since: v1.8 - returns: <[null]|[Response]> diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 570e391cb76f1..df03d2f229414 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -97,6 +97,8 @@ export class Page extends ChannelOwner implements api.Page _closeWasCalled: boolean = false; private _harRouters: HarRouter[] = []; + private _locatorHandlers = new Map(); + static from(page: channels.PageChannel): Page { return (page as any)._object; } @@ -133,6 +135,7 @@ export class Page extends ChannelOwner implements api.Page this._channel.on('fileChooser', ({ element, isMultiple }) => this.emit(Events.Page.FileChooser, new FileChooser(this, ElementHandle.from(element), isMultiple))); this._channel.on('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame))); this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(Frame.from(frame))); + this._channel.on('locatorHandlerTriggered', ({ uid }) => this._onLocatorHandlerTriggered(uid)); this._channel.on('route', ({ route }) => this._onRoute(Route.from(route))); this._channel.on('video', ({ artifact }) => { const artifactObject = Artifact.from(artifact); @@ -360,6 +363,22 @@ export class Page extends ChannelOwner implements api.Page return Response.fromNullable((await this._channel.reload({ ...options, waitUntil })).response); } + async handleLocator(locator: Locator, handler: Function): 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); + } + + private async _onLocatorHandlerTriggered(uid: number) { + try { + const handler = this._locatorHandlers.get(uid); + await handler?.(); + } finally { + this._channel.resolveLocatorHandlerNoReply({ uid }).catch(() => {}); + } + } + async waitForLoadState(state?: LifecycleEvent, options?: { timeout?: number }): Promise { return await this._mainFrame.waitForLoadState(state, options); } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 5a6e9363d8edb..fe523bf63d11f 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -983,6 +983,9 @@ scheme.PageFrameAttachedEvent = tObject({ scheme.PageFrameDetachedEvent = tObject({ frame: tChannel(['Frame']), }); +scheme.PageLocatorHandlerTriggeredEvent = tObject({ + uid: tNumber, +}); scheme.PageRouteEvent = tObject({ route: tChannel(['Route']), }); @@ -1038,6 +1041,16 @@ scheme.PageGoForwardParams = tObject({ scheme.PageGoForwardResult = tObject({ response: tOptional(tChannel(['Response'])), }); +scheme.PageRegisterLocatorHandlerParams = tObject({ + selector: tString, +}); +scheme.PageRegisterLocatorHandlerResult = tObject({ + uid: tNumber, +}); +scheme.PageResolveLocatorHandlerNoReplyParams = tObject({ + uid: tNumber, +}); +scheme.PageResolveLocatorHandlerNoReplyResult = 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 70ccf16638795..775fd515f04b1 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -85,6 +85,7 @@ export class PageDispatcher extends Dispatcher this._onFrameAttached(frame)); this.addObjectListener(Page.Events.FrameDetached, frame => this._onFrameDetached(frame)); + this.addObjectListener(Page.Events.LocatorHandlerTriggered, (uid: number) => this._dispatchEvent('locatorHandlerTriggered', { uid })); this.addObjectListener(Page.Events.WebSocket, webSocket => this._dispatchEvent('webSocket', { webSocket: new WebSocketDispatcher(this, webSocket) })); this.addObjectListener(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this, worker) })); this.addObjectListener(Page.Events.Video, (artifact: Artifact) => this._dispatchEvent('video', { artifact: ArtifactDispatcher.from(parentScope, artifact) })); @@ -136,6 +137,15 @@ export class PageDispatcher extends Dispatcher { + const uid = this._page.registerLocatorHandler(params.selector); + return { uid }; + } + + async resolveLocatorHandlerNoReply(params: channels.PageResolveLocatorHandlerNoReplyParams, metadata: CallMetadata): Promise { + this._page.resolveLocatorHandler(params.uid); + } + async emulateMedia(params: channels.PageEmulateMediaParams, metadata: CallMetadata): Promise { await this._page.emulateMedia({ media: params.media, diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 0fb196c4a221f..d2188aab5a179 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -288,7 +288,7 @@ export class ElementHandle extends js.JSHandle { }; } - async _retryAction(progress: Progress, actionName: string, action: (retry: number) => Promise, options: { trial?: boolean, force?: boolean }): Promise<'error:notconnected' | 'done'> { + async _retryAction(progress: Progress, actionName: string, action: (retry: number) => Promise, options: { trial?: boolean, force?: boolean, skipLocatorHandlersCheckpoint?: boolean }): Promise<'error:notconnected' | 'done'> { let retry = 0; // We progressively wait longer between retries, up to 500ms. const waitTime = [0, 20, 100, 100, 500]; @@ -306,6 +306,8 @@ export class ElementHandle extends js.JSHandle { } else { progress.log(`attempting ${actionName} action${options.trial ? ' (trial run)' : ''}`); } + if (!options.skipLocatorHandlersCheckpoint) + await this._frame._page.performLocatorHandlersCheckpoint(progress); const result = await action(retry); ++retry; if (result === 'error:notvisible') { @@ -339,6 +341,8 @@ export class ElementHandle extends js.JSHandle { async _retryPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> { + // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. + const skipLocatorHandlersCheckpoint = actionName === 'move and up'; return await this._retryAction(progress, actionName, async retry => { // By default, we scroll with protocol method to reveal the action point. // However, that might not work to scroll from under position:sticky elements @@ -352,7 +356,7 @@ export class ElementHandle extends js.JSHandle { ]; const forceScrollOptions = scrollOptions[retry % scrollOptions.length]; return await this._performPointerAction(progress, actionName, waitForEnabled, action, forceScrollOptions, options); - }, options); + }, { ...options, skipLocatorHandlersCheckpoint }); } async _performPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise, forceScrollOptions: ScrollIntoViewOptions | undefined, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 014ca2243e774..1a7f9d77ec07d 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1090,9 +1090,13 @@ export class Frame extends SdkObject { progress: Progress, selector: string, strict: boolean | undefined, + performLocatorHandlersCheckpoint: boolean, action: (handle: dom.ElementHandle) => Promise): Promise { progress.log(`waiting for ${this._asLocator(selector)}`); return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { + if (performLocatorHandlersCheckpoint) + await this._page.performLocatorHandlersCheckpoint(progress); + const resolved = await this.selectors.resolveInjectedForSelector(selector, { strict }); progress.throwIfAborted(); if (!resolved) @@ -1133,7 +1137,7 @@ export class Frame extends SdkObject { } async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise { - return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, async handle => { + return await this._retryWithProgressIfNotConnected(progress, selector, true /* strict */, true /* performLocatorHandlersCheckpoint */, async handle => { await handle._frame.rafrafTimeout(timeout); return await this._page._screenshotter.screenshotElement(progress, handle, options); }); @@ -1142,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, handle => handle._click(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* 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, handle => handle._dblclick(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* 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, async handle => { + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, source, options.strict, true /* 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(); @@ -1166,7 +1170,8 @@ export class Frame extends SdkObject { timeout: progress.timeUntilDeadline(), }); })); - dom.assertDone(await this._retryWithProgressIfNotConnected(progress, target, options.strict, async handle => { + // Note: do not perform locator handlers checkpoint to avoid moving the mouse in the middle of a drag operation. + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, target, options.strict, false /* performLocatorHandlersCheckpoint */, async handle => { return handle._retryPointerAction(progress, 'move and up', false, async point => { await this._page.mouse.move(point.x, point.y); await this._page.mouse.up(); @@ -1184,28 +1189,28 @@ 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, handle => handle._tap(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* 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, handle => handle._fill(progress, value, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._fill(progress, value, options))); }, this._page._timeoutSettings.timeout(options)); } async focus(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) { const controller = new ProgressController(metadata, this); await controller.run(async progress => { - dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, handle => handle._focus(progress))); + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._focus(progress))); }, this._page._timeoutSettings.timeout(options)); } async blur(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) { const controller = new ProgressController(metadata, this); await controller.run(async progress => { - dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, handle => handle._blur(progress))); + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._blur(progress))); }, this._page._timeoutSettings.timeout(options)); } @@ -1268,6 +1273,12 @@ export class Frame extends SdkObject { const controller = new ProgressController(metadata, this); return controller.run(async progress => { progress.log(` checking visibility of ${this._asLocator(selector)}`); + return await this.isVisibleInternal(selector, options, scope); + }, this._page._timeoutSettings.timeout({})); + } + + async isVisibleInternal(selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { + try { const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); if (!resolved) return false; @@ -1276,11 +1287,11 @@ export class Frame extends SdkObject { const state = element ? injected.elementState(element, 'visible') : false; return state === 'error:notconnected' ? false : state; }, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); - }, this._page._timeoutSettings.timeout({})).catch(e => { + } catch (e) { if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e)) throw e; return false; - }); + } } async isHidden(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise { @@ -1306,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, handle => handle._hover(progress, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* 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, handle => handle._selectOption(progress, elements, values, options)); + return await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._selectOption(progress, elements, values, options)); }, this._page._timeoutSettings.timeout(options)); } @@ -1321,35 +1332,35 @@ export class Frame extends SdkObject { const inputFileItems = await prepareFilesForUpload(this, params); const controller = new ProgressController(metadata, this); return controller.run(async progress => { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params.strict, handle => handle._setInputFiles(progress, inputFileItems, params))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._setInputFiles(progress, inputFileItems, params))); }, this._page._timeoutSettings.timeout(params)); } async type(metadata: CallMetadata, selector: string, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) { const controller = new ProgressController(metadata, this); return controller.run(async progress => { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, handle => handle._type(progress, text, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._type(progress, text, options))); }, this._page._timeoutSettings.timeout(options)); } async press(metadata: CallMetadata, selector: string, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) { const controller = new ProgressController(metadata, this); return controller.run(async progress => { - return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, handle => handle._press(progress, key, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._press(progress, key, options))); }, this._page._timeoutSettings.timeout(options)); } 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, handle => handle._setChecked(progress, true, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* 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, handle => handle._setChecked(progress, false, options))); + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performLocatorHandlersCheckpoint */, handle => handle._setChecked(progress, false, options))); }, this._page._timeoutSettings.timeout(options)); } @@ -1384,6 +1395,8 @@ export class Frame extends SdkObject { progress.log(`waiting for ${this._asLocator(selector)}`); } return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { + await this._page.performLocatorHandlersCheckpoint(progress); + const selectorInFrame = await this.selectors.resolveFrameForSelector(selector, { strict: true }); progress.throwIfAborted(); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index a00d09cfe3fab..0b043d1ff44b4 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -131,6 +131,7 @@ export class Page extends SdkObject { FrameAttached: 'frameattached', FrameDetached: 'framedetached', InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', + LocatorHandlerTriggered: 'locatorhandlertriggered', ScreencastFrame: 'screencastframe', Video: 'video', WebSocket: 'websocket', @@ -168,6 +169,9 @@ export class Page extends SdkObject { _video: Artifact | null = null; _opener: Page | undefined; private _isServerSideOnly = false; + private _locatorHandlers = new Map }>(); + private _lastLocatorHandlerUid = 0; + private _locatorHandlerRunningCounter = 0; // Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms. // When throttling for tracing, 200ms between frames, except for 10 frames around the action. @@ -249,6 +253,7 @@ export class Page extends SdkObject { async resetForReuse(metadata: CallMetadata) { this.setDefaultNavigationTimeout(undefined); this.setDefaultTimeout(undefined); + this._locatorHandlers.clear(); await this._removeExposedBindings(); await this._removeInitScripts(); @@ -428,6 +433,40 @@ export class Page extends SdkObject { }), this._timeoutSettings.navigationTimeout(options)); } + registerLocatorHandler(selector: string) { + const uid = ++this._lastLocatorHandlerUid; + this._locatorHandlers.set(uid, { selector }); + return uid; + } + + resolveLocatorHandler(uid: number) { + const handler = this._locatorHandlers.get(uid); + if (handler) { + handler.resolved?.resolve(); + handler.resolved = undefined; + } + } + + async performLocatorHandlersCheckpoint(progress: Progress) { + // 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); + } + } + if (handler.resolved) { + ++this._locatorHandlerRunningCounter; + await this.openScope.race(handler.resolved).finally(() => --this._locatorHandlerRunningCounter); + // Avoid side-effects after long-running operation. + progress.throwIfAborted(); + } + } + } + async emulateMedia(options: Partial) { if (options.media !== undefined) this._emulatedMedia.media = options.media; @@ -500,6 +539,7 @@ export class Page extends SdkObject { const rafrafScreenshot = locator ? async (progress: Progress, timeout: number) => { return await locator.frame.rafrafTimeoutScreenshotElementWithProgress(progress, locator.selector, timeout, options.screenshotOptions || {}); } : async (progress: Progress, timeout: number) => { + await this.performLocatorHandlersCheckpoint(progress); await this.mainFrame().rafrafTimeout(timeout); return await this._screenshotter.screenshotPage(progress, options.screenshotOptions || {}); }; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4ce24d9297b43..7a4e941acd1df 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2926,6 +2926,66 @@ export interface Page { waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit"; }): Promise; + /** + * 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](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. + * + * Note that execution time of the handler counts towards the timeout of the action/assertion that executed the + * handler. + * + * **Usage** + * + * An example that closes a cookie dialog when it appears: + * + * ```js + * // Setup the handler. + * await page.handleLocator(page.getByRole('button', { name: 'Accept all cookies' }), async () => { + * await page.getByRole('button', { name: 'Reject all cookies' }).click(); + * }); + * + * // Write the test as usual. + * await page.goto('https://example.com'); + * await page.getByRole('button', { name: 'Start here' }).click(); + * ``` + * + * An example that skips the "Confirm your security details" page when it is shown: + * + * ```js + * // Setup the handler. + * await page.handleLocator(page.getByText('Confirm your security details'), async () => { + * await page.getByRole('button', 'Remind me later').click(); + * }); + * + * // Write the test as usual. + * await page.goto('https://example.com'); + * await page.getByRole('button', { name: 'Start here' }).click(); + * ``` + * + * 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: + * + * ```js + * // Setup the handler. + * await page.handleLocator(page.locator('body'), async () => { + * await page.evaluate(() => window.removeObstructionsForTestIfNeeded()); + * }); + * + * // Write the test as usual. + * await page.goto('https://example.com'); + * await page.getByRole('button', { name: 'Start here' }).click(); + * ``` + * + * @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. + */ + handleLocator(locator: Locator, handler: Function): Promise; + /** * **NOTE** Use locator-based [locator.hover([options])](https://playwright.dev/docs/api/class-locator#locator-hover) instead. * Read more about [locators](https://playwright.dev/docs/locators). diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index cf2cd36501446..2ff7b258444a1 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1761,6 +1761,7 @@ export interface PageEventTarget { on(event: 'fileChooser', callback: (params: PageFileChooserEvent) => void): this; on(event: 'frameAttached', callback: (params: PageFrameAttachedEvent) => void): this; on(event: 'frameDetached', callback: (params: PageFrameDetachedEvent) => void): this; + on(event: 'locatorHandlerTriggered', callback: (params: PageLocatorHandlerTriggeredEvent) => void): this; on(event: 'route', callback: (params: PageRouteEvent) => void): this; on(event: 'video', callback: (params: PageVideoEvent) => void): this; on(event: 'webSocket', callback: (params: PageWebSocketEvent) => void): this; @@ -1776,6 +1777,8 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise; goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise; goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise; + registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise; + resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise; reload(params: PageReloadParams, metadata?: CallMetadata): Promise; expectScreenshot(params: PageExpectScreenshotParams, metadata?: CallMetadata): Promise; screenshot(params: PageScreenshotParams, metadata?: CallMetadata): Promise; @@ -1822,6 +1825,9 @@ export type PageFrameAttachedEvent = { export type PageFrameDetachedEvent = { frame: FrameChannel, }; +export type PageLocatorHandlerTriggeredEvent = { + uid: number, +}; export type PageRouteEvent = { route: RouteChannel, }; @@ -1907,6 +1913,22 @@ export type PageGoForwardOptions = { export type PageGoForwardResult = { response?: ResponseChannel, }; +export type PageRegisterLocatorHandlerParams = { + selector: string, +}; +export type PageRegisterLocatorHandlerOptions = { + +}; +export type PageRegisterLocatorHandlerResult = { + uid: number, +}; +export type PageResolveLocatorHandlerNoReplyParams = { + uid: number, +}; +export type PageResolveLocatorHandlerNoReplyOptions = { + +}; +export type PageResolveLocatorHandlerNoReplyResult = void; export type PageReloadParams = { timeout?: number, waitUntil?: LifecycleEvent, @@ -2258,6 +2280,7 @@ export interface PageEvents { 'fileChooser': PageFileChooserEvent; 'frameAttached': PageFrameAttachedEvent; 'frameDetached': PageFrameDetachedEvent; + 'locatorHandlerTriggered': PageLocatorHandlerTriggeredEvent; 'route': PageRouteEvent; 'video': PageVideoEvent; 'webSocket': PageWebSocketEvent; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 7eb7a96a9be28..eba4e5cc39eaa 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1349,6 +1349,16 @@ Page: slowMo: true snapshot: true + registerLocatorHandler: + parameters: + selector: string + returns: + uid: number + + resolveLocatorHandlerNoReply: + parameters: + uid: number + reload: parameters: timeout: number? @@ -1668,6 +1678,10 @@ Page: parameters: frame: Frame + locatorHandlerTriggered: + parameters: + uid: number + route: parameters: route: Route diff --git a/tests/assets/input/handle-locator.html b/tests/assets/input/handle-locator.html new file mode 100644 index 0000000000000..abefafd7a2e59 --- /dev/null +++ b/tests/assets/input/handle-locator.html @@ -0,0 +1,79 @@ + + + + Interstitial test + + + +
+
A place on the side to hover
+
+
This interstitial covers the button
+ +
+ + + diff --git a/tests/page/page-handle-locator.spec.ts b/tests/page/page-handle-locator.spec.ts new file mode 100644 index 0000000000000..e7c829218f4c4 --- /dev/null +++ b/tests/page/page-handle-locator.spec.ts @@ -0,0 +1,172 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './pageTest'; +import { kTargetClosedErrorMessage } from '../config/errors'; + +test('should work', async ({ page, server }) => { + await page.goto(server.PREFIX + '/input/handle-locator.html'); + + let beforeCount = 0; + let afterCount = 0; + await page.handleLocator(page.getByText('This interstitial covers the button'), async () => { + ++beforeCount; + await page.locator('#close').click(); + ++afterCount; + }); + + for (const args of [ + ['mouseover', 1], + ['mouseover', 1, 'capture'], + ['mouseover', 2], + ['mouseover', 2, 'capture'], + ['pointerover', 1], + ['pointerover', 1, 'capture'], + ['none', 1], + ['remove', 1], + ['hide', 1], + ]) { + await test.step(`${args[0]}${args[2] === 'capture' ? ' with capture' : ''} ${args[1]} times`, async () => { + await page.locator('#aside').hover(); + beforeCount = 0; + afterCount = 0; + await page.evaluate(args => { + (window as any).clicked = 0; + (window as any).setupAnnoyingInterstitial(...args); + }, args); + expect(beforeCount).toBe(0); + expect(afterCount).toBe(0); + await page.locator('#target').click(); + expect(beforeCount).toBe(args[1]); + expect(afterCount).toBe(args[1]); + expect(await page.evaluate('window.clicked')).toBe(1); + await expect(page.locator('#interstitial')).not.toBeVisible(); + }); + } +}); + +test('should work with a custom check', async ({ page, server }) => { + await page.goto(server.PREFIX + '/input/handle-locator.html'); + + await page.handleLocator(page.locator('body'), async () => { + if (await page.getByText('This interstitial covers the button').isVisible()) + await page.locator('#close').click(); + }); + + for (const args of [ + ['mouseover', 2], + ['none', 1], + ['remove', 1], + ['hide', 1], + ]) { + await test.step(`${args[0]}${args[2] === 'capture' ? ' with capture' : ''} ${args[1]} times`, async () => { + await page.locator('#aside').hover(); + await page.evaluate(args => { + (window as any).clicked = 0; + (window as any).setupAnnoyingInterstitial(...args); + }, args); + await page.locator('#target').click(); + expect(await page.evaluate('window.clicked')).toBe(1); + await expect(page.locator('#interstitial')).not.toBeVisible(); + }); + } +}); + +test('should throw when page closes', 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.close(); + }); + + await page.locator('#aside').hover(); + await page.evaluate(() => { + (window as any).clicked = 0; + (window as any).setupAnnoyingInterstitial('mouseover', 1); + }); + const error = await page.locator('#target').click().catch(e => e); + expect(error.message).toContain(kTargetClosedErrorMessage); +}); + +test('should throw when handler times out', async ({ page, server }) => { + await page.goto(server.PREFIX + '/input/handle-locator.html'); + + let called = 0; + await page.handleLocator(page.getByText('This interstitial covers the button'), async () => { + ++called; + // Deliberately timeout. + await new Promise(() => {}); + }); + + await page.locator('#aside').hover(); + await page.evaluate(() => { + (window as any).clicked = 0; + (window as any).setupAnnoyingInterstitial('mouseover', 1); + }); + const error = await page.locator('#target').click({ timeout: 3000 }).catch(e => e); + expect(error.message).toContain('Timeout 3000ms exceeded'); + + const error2 = await page.locator('#target').click({ timeout: 3000 }).catch(e => e); + expect(error2.message).toContain('Timeout 3000ms exceeded'); + + // Should not enter the same handler while it is still running. + expect(called).toBe(1); +}); + +test('should work with toBeVisible', async ({ page, server }) => { + await page.goto(server.PREFIX + '/input/handle-locator.html'); + + let called = 0; + await page.handleLocator(page.getByText('This interstitial covers the button'), async () => { + ++called; + await page.locator('#close').click(); + }); + + await page.evaluate(() => { + (window as any).clicked = 0; + (window as any).setupAnnoyingInterstitial('remove', 1); + }); + await expect(page.locator('#target')).toBeVisible(); + await expect(page.locator('#interstitial')).not.toBeVisible(); + expect(called).toBe(1); +}); + +test('should work with toHaveScreenshot', async ({ page, server }) => { + await page.setViewportSize({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + + await page.evaluate(() => { + const overlay = document.createElement('div'); + document.body.append(overlay); + overlay.style.position = 'absolute'; + overlay.style.left = '0'; + overlay.style.right = '0'; + overlay.style.top = '0'; + overlay.style.bottom = '0'; + overlay.style.backgroundColor = 'red'; + + const closeButton = document.createElement('button'); + overlay.appendChild(closeButton); + closeButton.textContent = 'close'; + closeButton.addEventListener('click', () => overlay.remove()); + }); + + await page.handleLocator(page.getByRole('button', { name: 'close' }), async () => { + await page.getByRole('button', { name: 'close' }).click(); + }); + + await expect(page).toHaveScreenshot('screenshot-grid.png'); +}); diff --git a/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-chromium.png b/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-chromium.png new file mode 100644 index 0000000000000..122a4f0ae04d9 Binary files /dev/null and b/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-chromium.png differ diff --git a/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-firefox.png b/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-firefox.png new file mode 100644 index 0000000000000..7b164933553a5 Binary files /dev/null and b/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-firefox.png differ diff --git a/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-webkit.png b/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-webkit.png new file mode 100644 index 0000000000000..af070c52a8cd3 Binary files /dev/null and b/tests/page/page-handle-locator.spec.ts-snapshots/screenshot-grid-webkit.png differ