Skip to content

Commit

Permalink
feat: page.handleLocator that closes any interstitial pages/dialogs (
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman authored Jan 19, 2024
1 parent 71a48c2 commit f3fac6f
Show file tree
Hide file tree
Showing 15 changed files with 663 additions and 20 deletions.
196 changes: 196 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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
* since: v1.8
- returns: <[null]|[Response]>
Expand Down
19 changes: 19 additions & 0 deletions packages/playwright-core/src/client/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
_closeWasCalled: boolean = false;
private _harRouters: HarRouter[] = [];

private _locatorHandlers = new Map<number, Function>();

static from(page: channels.PageChannel): Page {
return (page as any)._object;
}
Expand Down Expand Up @@ -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('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);
Expand Down Expand Up @@ -360,6 +363,22 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
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> {
return await this._mainFrame.waitForLoadState(state, options);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,9 @@ scheme.PageFrameAttachedEvent = tObject({
scheme.PageFrameDetachedEvent = tObject({
frame: tChannel(['Frame']),
});
scheme.PageLocatorHandlerTriggeredEvent = tObject({
uid: tNumber,
});
scheme.PageRouteEvent = tObject({
route: tChannel(['Route']),
});
Expand Down Expand Up @@ -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')),
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.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) }));
Expand Down Expand Up @@ -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)) };
}

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> {
await this._page.emulateMedia({
media: params.media,
Expand Down
8 changes: 6 additions & 2 deletions packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
// We progressively wait longer between retries, up to 500ms.
const waitTime = [0, 20, 100, 100, 500];
Expand All @@ -306,6 +306,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} 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') {
Expand Down Expand Up @@ -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>,
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
Expand All @@ -352,7 +356,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
];
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<void>, forceScrollOptions: ScrollIntoViewOptions | undefined, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<PerformActionResult> {
Expand Down
Loading

0 comments on commit f3fac6f

Please sign in to comment.