feat: page.handleLocator that closes any interstitial pages/dialogs (#29029)

This commit is contained in:
Dmitry Gozman 2024-01-19 12:35:00 -08:00 committed by GitHub
parent 71a48c2562
commit f3fac6f4e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 663 additions and 20 deletions

View File

@ -3130,6 +3130,202 @@ return value resolves to `[]`.
### param: Page.querySelectorAll.selector = %%-query-selector-%% ### param: Page.querySelectorAll.selector = %%-query-selector-%%
* since: v1.9 * 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 `<body>` 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 ## async method: Page.reload
* since: v1.8 * since: v1.8
- returns: <[null]|[Response]> - returns: <[null]|[Response]>

View File

@ -97,6 +97,8 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
_closeWasCalled: boolean = false; _closeWasCalled: boolean = false;
private _harRouters: HarRouter[] = []; private _harRouters: HarRouter[] = [];
private _locatorHandlers = new Map<number, Function>();
static from(page: channels.PageChannel): Page { static from(page: channels.PageChannel): Page {
return (page as any)._object; return (page as any)._object;
} }
@ -133,6 +135,7 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
this._channel.on('fileChooser', ({ element, isMultiple }) => this.emit(Events.Page.FileChooser, new FileChooser(this, ElementHandle.from(element), isMultiple))); 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('frameAttached', ({ frame }) => this._onFrameAttached(Frame.from(frame)));
this._channel.on('frameDetached', ({ frame }) => this._onFrameDetached(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('route', ({ route }) => this._onRoute(Route.from(route)));
this._channel.on('video', ({ artifact }) => { this._channel.on('video', ({ artifact }) => {
const artifactObject = Artifact.from(artifact); const artifactObject = Artifact.from(artifact);
@ -360,6 +363,22 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return Response.fromNullable((await this._channel.reload({ ...options, waitUntil })).response); return Response.fromNullable((await this._channel.reload({ ...options, waitUntil })).response);
} }
async handleLocator(locator: Locator, handler: Function): 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);
}
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<void> { async waitForLoadState(state?: LifecycleEvent, options?: { timeout?: number }): Promise<void> {
return await this._mainFrame.waitForLoadState(state, options); return await this._mainFrame.waitForLoadState(state, options);
} }

View File

@ -983,6 +983,9 @@ scheme.PageFrameAttachedEvent = tObject({
scheme.PageFrameDetachedEvent = tObject({ scheme.PageFrameDetachedEvent = tObject({
frame: tChannel(['Frame']), frame: tChannel(['Frame']),
}); });
scheme.PageLocatorHandlerTriggeredEvent = tObject({
uid: tNumber,
});
scheme.PageRouteEvent = tObject({ scheme.PageRouteEvent = tObject({
route: tChannel(['Route']), route: tChannel(['Route']),
}); });
@ -1038,6 +1041,16 @@ scheme.PageGoForwardParams = tObject({
scheme.PageGoForwardResult = tObject({ scheme.PageGoForwardResult = tObject({
response: tOptional(tChannel(['Response'])), 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({ scheme.PageReloadParams = tObject({
timeout: tOptional(tNumber), timeout: tOptional(tNumber),
waitUntil: tOptional(tType('LifecycleEvent')), waitUntil: tOptional(tType('LifecycleEvent')),

View File

@ -85,6 +85,7 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
})); }));
this.addObjectListener(Page.Events.FrameAttached, frame => this._onFrameAttached(frame)); this.addObjectListener(Page.Events.FrameAttached, frame => this._onFrameAttached(frame));
this.addObjectListener(Page.Events.FrameDetached, frame => this._onFrameDetached(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.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.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) })); this.addObjectListener(Page.Events.Video, (artifact: Artifact) => this._dispatchEvent('video', { artifact: ArtifactDispatcher.from(parentScope, artifact) }));
@ -136,6 +137,15 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goForward(metadata, params)) }; return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goForward(metadata, params)) };
} }
async registerLocatorHandler(params: channels.PageRegisterLocatorHandlerParams, metadata: CallMetadata): Promise<channels.PageRegisterLocatorHandlerResult> {
const uid = this._page.registerLocatorHandler(params.selector);
return { uid };
}
async resolveLocatorHandlerNoReply(params: channels.PageResolveLocatorHandlerNoReplyParams, metadata: CallMetadata): Promise<void> {
this._page.resolveLocatorHandler(params.uid);
}
async emulateMedia(params: channels.PageEmulateMediaParams, metadata: CallMetadata): Promise<void> { async emulateMedia(params: channels.PageEmulateMediaParams, metadata: CallMetadata): Promise<void> {
await this._page.emulateMedia({ await this._page.emulateMedia({
media: params.media, media: params.media,

View File

@ -288,7 +288,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}; };
} }
async _retryAction(progress: Progress, actionName: string, action: (retry: number) => Promise<PerformActionResult>, options: { trial?: boolean, force?: boolean }): Promise<'error:notconnected' | 'done'> { async _retryAction(progress: Progress, actionName: string, action: (retry: number) => Promise<PerformActionResult>, options: { trial?: boolean, force?: boolean, skipLocatorHandlersCheckpoint?: boolean }): Promise<'error:notconnected' | 'done'> {
let retry = 0; let retry = 0;
// We progressively wait longer between retries, up to 500ms. // We progressively wait longer between retries, up to 500ms.
const waitTime = [0, 20, 100, 100, 500]; const waitTime = [0, 20, 100, 100, 500];
@ -306,6 +306,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} else { } else {
progress.log(`attempting ${actionName} action${options.trial ? ' (trial run)' : ''}`); progress.log(`attempting ${actionName} action${options.trial ? ' (trial run)' : ''}`);
} }
if (!options.skipLocatorHandlersCheckpoint)
await this._frame._page.performLocatorHandlersCheckpoint(progress);
const result = await action(retry); const result = await action(retry);
++retry; ++retry;
if (result === 'error:notvisible') { if (result === 'error:notvisible') {
@ -339,6 +341,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _retryPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>, async _retryPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>,
options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> { 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 => { return await this._retryAction(progress, actionName, async retry => {
// By default, we scroll with protocol method to reveal the action point. // By default, we scroll with protocol method to reveal the action point.
// However, that might not work to scroll from under position:sticky elements // However, that might not work to scroll from under position:sticky elements
@ -352,7 +356,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
]; ];
const forceScrollOptions = scrollOptions[retry % scrollOptions.length]; const forceScrollOptions = scrollOptions[retry % scrollOptions.length];
return await this._performPointerAction(progress, actionName, waitForEnabled, action, forceScrollOptions, options); 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<void>, forceScrollOptions: ScrollIntoViewOptions | undefined, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<PerformActionResult> { async _performPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>, forceScrollOptions: ScrollIntoViewOptions | undefined, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<PerformActionResult> {

View File

@ -1090,9 +1090,13 @@ export class Frame extends SdkObject {
progress: Progress, progress: Progress,
selector: string, selector: string,
strict: boolean | undefined, strict: boolean | undefined,
performLocatorHandlersCheckpoint: boolean,
action: (handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> { action: (handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
progress.log(`waiting for ${this._asLocator(selector)}`); progress.log(`waiting for ${this._asLocator(selector)}`);
return this.retryWithProgressAndTimeouts(progress, [0, 20, 50, 100, 100, 500], async continuePolling => { 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 }); const resolved = await this.selectors.resolveInjectedForSelector(selector, { strict });
progress.throwIfAborted(); progress.throwIfAborted();
if (!resolved) if (!resolved)
@ -1133,7 +1137,7 @@ export class Frame extends SdkObject {
} }
async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise<Buffer> { async rafrafTimeoutScreenshotElementWithProgress(progress: Progress, selector: string, timeout: number, options: ScreenshotOptions): Promise<Buffer> {
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); await handle._frame.rafrafTimeout(timeout);
return await this._page._screenshotter.screenshotElement(progress, handle, options); 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) { async click(metadata: CallMetadata, selector: string, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async dblclick(metadata: CallMetadata, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async dblclick(metadata: CallMetadata, selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async dragAndDrop(metadata: CallMetadata, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async dragAndDrop(metadata: CallMetadata, source: string, target: string, options: types.DragActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
await controller.run(async progress => { 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 => { return handle._retryPointerAction(progress, 'move and down', false, async point => {
await this._page.mouse.move(point.x, point.y); await this._page.mouse.move(point.x, point.y);
await this._page.mouse.down(); await this._page.mouse.down();
@ -1166,7 +1170,8 @@ export class Frame extends SdkObject {
timeout: progress.timeUntilDeadline(), 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 => { return handle._retryPointerAction(progress, 'move and up', false, async point => {
await this._page.mouse.move(point.x, point.y); await this._page.mouse.move(point.x, point.y);
await this._page.mouse.up(); 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.'); throw new Error('The page does not support tap. Use hasTouch context option to enable touch support.');
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async fill(metadata: CallMetadata, selector: string, value: string, options: types.NavigatingActionWaitOptions & { force?: boolean }) { async fill(metadata: CallMetadata, selector: string, value: string, options: types.NavigatingActionWaitOptions & { force?: boolean }) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async focus(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) { async focus(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
await controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async blur(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) { async blur(metadata: CallMetadata, selector: string, options: types.TimeoutOptions & types.StrictOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
await controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
@ -1268,6 +1273,12 @@ export class Frame extends SdkObject {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { return controller.run(async progress => {
progress.log(` checking visibility of ${this._asLocator(selector)}`); 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<boolean> {
try {
const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope); const resolved = await this.selectors.resolveInjectedForSelector(selector, options, scope);
if (!resolved) if (!resolved)
return false; return false;
@ -1276,11 +1287,11 @@ export class Frame extends SdkObject {
const state = element ? injected.elementState(element, 'visible') : false; const state = element ? injected.elementState(element, 'visible') : false;
return state === 'error:notconnected' ? false : state; return state === 'error:notconnected' ? false : state;
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined }); }, { 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)) if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e))
throw e; throw e;
return false; return false;
}); }
} }
async isHidden(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise<boolean> { async isHidden(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise<boolean> {
@ -1306,14 +1317,14 @@ export class Frame extends SdkObject {
async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async hover(metadata: CallMetadata, selector: string, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async selectOption(metadata: CallMetadata, selector: string, elements: dom.ElementHandle[], values: types.SelectOption[], options: types.NavigatingActionWaitOptions & types.ForceOptions = {}): Promise<string[]> { async selectOption(metadata: CallMetadata, selector: string, elements: dom.ElementHandle[], values: types.SelectOption[], options: types.NavigatingActionWaitOptions & types.ForceOptions = {}): Promise<string[]> {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
@ -1321,35 +1332,35 @@ export class Frame extends SdkObject {
const inputFileItems = await prepareFilesForUpload(this, params); const inputFileItems = await prepareFilesForUpload(this, params);
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(params));
} }
async type(metadata: CallMetadata, selector: string, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) { async type(metadata: CallMetadata, selector: string, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async press(metadata: CallMetadata, selector: string, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) { async press(metadata: CallMetadata, selector: string, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async check(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async check(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
async uncheck(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async uncheck(metadata: CallMetadata, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const controller = new ProgressController(metadata, this); const controller = new ProgressController(metadata, this);
return controller.run(async progress => { 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)); }, this._page._timeoutSettings.timeout(options));
} }
@ -1384,6 +1395,8 @@ export class Frame extends SdkObject {
progress.log(`waiting for ${this._asLocator(selector)}`); progress.log(`waiting for ${this._asLocator(selector)}`);
} }
return await this.retryWithProgressAndTimeouts(progress, [100, 250, 500, 1000], async continuePolling => { 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 }); const selectorInFrame = await this.selectors.resolveFrameForSelector(selector, { strict: true });
progress.throwIfAborted(); progress.throwIfAborted();

View File

@ -131,6 +131,7 @@ export class Page extends SdkObject {
FrameAttached: 'frameattached', FrameAttached: 'frameattached',
FrameDetached: 'framedetached', FrameDetached: 'framedetached',
InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument',
LocatorHandlerTriggered: 'locatorhandlertriggered',
ScreencastFrame: 'screencastframe', ScreencastFrame: 'screencastframe',
Video: 'video', Video: 'video',
WebSocket: 'websocket', WebSocket: 'websocket',
@ -168,6 +169,9 @@ export class Page extends SdkObject {
_video: Artifact | null = null; _video: Artifact | null = null;
_opener: Page | undefined; _opener: Page | undefined;
private _isServerSideOnly = false; private _isServerSideOnly = false;
private _locatorHandlers = new Map<number, { selector: string, resolved?: ManualPromise<void> }>();
private _lastLocatorHandlerUid = 0;
private _locatorHandlerRunningCounter = 0;
// Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms. // 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. // 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) { async resetForReuse(metadata: CallMetadata) {
this.setDefaultNavigationTimeout(undefined); this.setDefaultNavigationTimeout(undefined);
this.setDefaultTimeout(undefined); this.setDefaultTimeout(undefined);
this._locatorHandlers.clear();
await this._removeExposedBindings(); await this._removeExposedBindings();
await this._removeInitScripts(); await this._removeInitScripts();
@ -428,6 +433,40 @@ export class Page extends SdkObject {
}), this._timeoutSettings.navigationTimeout(options)); }), 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<EmulatedMedia>) { async emulateMedia(options: Partial<EmulatedMedia>) {
if (options.media !== undefined) if (options.media !== undefined)
this._emulatedMedia.media = options.media; this._emulatedMedia.media = options.media;
@ -500,6 +539,7 @@ export class Page extends SdkObject {
const rafrafScreenshot = locator ? async (progress: Progress, timeout: number) => { const rafrafScreenshot = locator ? async (progress: Progress, timeout: number) => {
return await locator.frame.rafrafTimeoutScreenshotElementWithProgress(progress, locator.selector, timeout, options.screenshotOptions || {}); return await locator.frame.rafrafTimeoutScreenshotElementWithProgress(progress, locator.selector, timeout, options.screenshotOptions || {});
} : async (progress: Progress, timeout: number) => { } : async (progress: Progress, timeout: number) => {
await this.performLocatorHandlersCheckpoint(progress);
await this.mainFrame().rafrafTimeout(timeout); await this.mainFrame().rafrafTimeout(timeout);
return await this._screenshotter.screenshotPage(progress, options.screenshotOptions || {}); return await this._screenshotter.screenshotPage(progress, options.screenshotOptions || {});
}; };

View File

@ -2926,6 +2926,66 @@ export interface Page {
waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit"; waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit";
}): Promise<null|Response>; }): Promise<null|Response>;
/**
* 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 `<body>` 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<void>;
/** /**
* **NOTE** Use locator-based [locator.hover([options])](https://playwright.dev/docs/api/class-locator#locator-hover) instead. * **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). * Read more about [locators](https://playwright.dev/docs/locators).

View File

@ -1761,6 +1761,7 @@ export interface PageEventTarget {
on(event: 'fileChooser', callback: (params: PageFileChooserEvent) => void): this; on(event: 'fileChooser', callback: (params: PageFileChooserEvent) => void): this;
on(event: 'frameAttached', callback: (params: PageFrameAttachedEvent) => void): this; on(event: 'frameAttached', callback: (params: PageFrameAttachedEvent) => void): this;
on(event: 'frameDetached', callback: (params: PageFrameDetachedEvent) => 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: 'route', callback: (params: PageRouteEvent) => void): this;
on(event: 'video', callback: (params: PageVideoEvent) => void): this; on(event: 'video', callback: (params: PageVideoEvent) => void): this;
on(event: 'webSocket', callback: (params: PageWebSocketEvent) => 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<PageExposeBindingResult>; exposeBinding(params: PageExposeBindingParams, metadata?: CallMetadata): Promise<PageExposeBindingResult>;
goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise<PageGoBackResult>; goBack(params: PageGoBackParams, metadata?: CallMetadata): Promise<PageGoBackResult>;
goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise<PageGoForwardResult>; goForward(params: PageGoForwardParams, metadata?: CallMetadata): Promise<PageGoForwardResult>;
registerLocatorHandler(params: PageRegisterLocatorHandlerParams, metadata?: CallMetadata): Promise<PageRegisterLocatorHandlerResult>;
resolveLocatorHandlerNoReply(params: PageResolveLocatorHandlerNoReplyParams, metadata?: CallMetadata): Promise<PageResolveLocatorHandlerNoReplyResult>;
reload(params: PageReloadParams, metadata?: CallMetadata): Promise<PageReloadResult>; reload(params: PageReloadParams, metadata?: CallMetadata): Promise<PageReloadResult>;
expectScreenshot(params: PageExpectScreenshotParams, metadata?: CallMetadata): Promise<PageExpectScreenshotResult>; expectScreenshot(params: PageExpectScreenshotParams, metadata?: CallMetadata): Promise<PageExpectScreenshotResult>;
screenshot(params: PageScreenshotParams, metadata?: CallMetadata): Promise<PageScreenshotResult>; screenshot(params: PageScreenshotParams, metadata?: CallMetadata): Promise<PageScreenshotResult>;
@ -1822,6 +1825,9 @@ export type PageFrameAttachedEvent = {
export type PageFrameDetachedEvent = { export type PageFrameDetachedEvent = {
frame: FrameChannel, frame: FrameChannel,
}; };
export type PageLocatorHandlerTriggeredEvent = {
uid: number,
};
export type PageRouteEvent = { export type PageRouteEvent = {
route: RouteChannel, route: RouteChannel,
}; };
@ -1907,6 +1913,22 @@ export type PageGoForwardOptions = {
export type PageGoForwardResult = { export type PageGoForwardResult = {
response?: ResponseChannel, 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 = { export type PageReloadParams = {
timeout?: number, timeout?: number,
waitUntil?: LifecycleEvent, waitUntil?: LifecycleEvent,
@ -2258,6 +2280,7 @@ export interface PageEvents {
'fileChooser': PageFileChooserEvent; 'fileChooser': PageFileChooserEvent;
'frameAttached': PageFrameAttachedEvent; 'frameAttached': PageFrameAttachedEvent;
'frameDetached': PageFrameDetachedEvent; 'frameDetached': PageFrameDetachedEvent;
'locatorHandlerTriggered': PageLocatorHandlerTriggeredEvent;
'route': PageRouteEvent; 'route': PageRouteEvent;
'video': PageVideoEvent; 'video': PageVideoEvent;
'webSocket': PageWebSocketEvent; 'webSocket': PageWebSocketEvent;

View File

@ -1349,6 +1349,16 @@ Page:
slowMo: true slowMo: true
snapshot: true snapshot: true
registerLocatorHandler:
parameters:
selector: string
returns:
uid: number
resolveLocatorHandlerNoReply:
parameters:
uid: number
reload: reload:
parameters: parameters:
timeout: number? timeout: number?
@ -1668,6 +1678,10 @@ Page:
parameters: parameters:
frame: Frame frame: Frame
locatorHandlerTriggered:
parameters:
uid: number
route: route:
parameters: parameters:
route: Route route: Route

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<title>Interstitial test</title>
</head>
<body>
<style>
body {
position: relative;
}
#target.removed {
display: none;
}
#target.hidden {
visibility: hidden;
}
#interstitial {
position: absolute;
top: 0;
left: 0;
width: 300px;
height: 300px;
border: 1px solid black;
background: rgba(255, 180, 180);
display: none;
}
#interstitial.visible {
display: block;
}
#close {
margin: 50px;
}
</style>
<div><button id="target">Click me</button></div>
<div id="aside">A place on the side to hover</div>
<div id="interstitial">
<div>This interstitial covers the button</div>
<button id="close">Close the interstitial</button>
</div>
<script>
const target = document.querySelector('#target');
const interstitial = document.querySelector('#interstitial');
const close = document.querySelector('#close');
target.addEventListener('click', () => {
window.clicked = (window.clicked ?? 0) + 1;
}, false);
close.addEventListener('click', () => {
interstitial.classList.remove('visible');
target.classList.remove('hidden');
target.classList.remove('removed');
});
let timesToShow = 0;
function setupAnnoyingInterstitial(event, times, capture) {
timesToShow = times;
const listener = () => {
timesToShow--;
interstitial.classList.add('visible');
interstitial.getBoundingClientRect();
if (!timesToShow && event !== 'none')
target.removeEventListener(event, listener, capture === 'capture');
};
if (event === 'hide') {
target.classList.add('hidden');
listener();
} else if (event === 'remove') {
target.classList.add('removed');
listener();
} else if (event === 'none') {
listener();
} else {
target.addEventListener(event, listener, capture === 'capture');
}
}
</script>
</body>
</html>

View File

@ -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');
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB