From 2b055b3092bd1a1073f1a4d8295458899ee454b6 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 28 Sep 2021 13:57:11 -0700 Subject: [PATCH] feat(api): introduce locator.waitFor (#9200) --- docs/src/api/class-locator.md | 35 +++++++++++++++++++++++++++++++ src/client/locator.ts | 8 +++++++ src/protocol/channels.ts | 2 ++ src/protocol/protocol.yml | 1 + src/protocol/validator.ts | 1 + src/server/dom.ts | 18 +++++++++------- src/server/frames.ts | 4 ++-- tests/page/locator-misc-2.spec.ts | 17 +++++++++++++++ types/types.d.ts | 34 ++++++++++++++++++++++++++++++ 9 files changed, 111 insertions(+), 9 deletions(-) diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 19efa9ecf6..181011fbd5 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -977,3 +977,38 @@ When all steps combined have not finished during the specified [`option: timeout ### option: Locator.uncheck.noWaitAfter = %%-input-no-wait-after-%% ### option: Locator.uncheck.timeout = %%-input-timeout-%% ### option: Locator.uncheck.trial = %%-input-trial-%% + +## async method: Locator.waitFor + +Returns when element specified by locator satisfies the [`option: state`] option. + +If target element already satisfies the condition, the method returns immediately. Otherwise, waits for up to +[`option: timeout`] milliseconds until the condition is met. + +```js +const orderSent = page.locator('#order-sent'); +await orderSent.waitFor(); +``` + +```java +Locator orderSent = page.locator("#order-sent"); +orderSent.waitFor(); +``` + +```python async +order_sent = page.locator("#order-sent") +await order_sent.wait_for() +``` + +```python sync +order_sent = page.locator("#order-sent") +order_sent.wait_for() +``` + +```csharp +var orderSent = page.Locator("#order-sent"); +orderSent.WaitForAsync(); +``` + +### option: Locator.waitFor.state = %%-wait-for-selector-state-%% +### option: Locator.waitFor.timeout = %%-input-timeout-%% diff --git a/src/client/locator.ts b/src/client/locator.ts index 913ea6a496..78486f4e32 100644 --- a/src/client/locator.ts +++ b/src/client/locator.ts @@ -213,6 +213,14 @@ export class Locator implements api.Locator { return this._frame.$$eval(this._selector, ee => ee.map(e => e.textContent || '')); } + waitFor(options: channels.FrameWaitForSelectorOptions & { state: 'attached' | 'visible' }): Promise; + waitFor(options?: channels.FrameWaitForSelectorOptions): Promise; + async waitFor(options?: channels.FrameWaitForSelectorOptions): Promise { + return this._frame._wrapApiCall(async (channel: channels.FrameChannel) => { + await channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options }); + }); + } + async _expect(expression: string, options: channels.FrameExpectOptions): Promise<{ pass: boolean, received?: any, log?: string[] }> { return this._frame._wrapApiCall(async (channel: channels.FrameChannel) => { const params: any = { selector: this._selector, expression, ...options }; diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index dd329524c5..ab707a18bb 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -2171,11 +2171,13 @@ export type FrameWaitForSelectorParams = { strict?: boolean, timeout?: number, state?: 'attached' | 'detached' | 'visible' | 'hidden', + omitReturnValue?: boolean, }; export type FrameWaitForSelectorOptions = { strict?: boolean, timeout?: number, state?: 'attached' | 'detached' | 'visible' | 'hidden', + omitReturnValue?: boolean, }; export type FrameWaitForSelectorResult = { element?: ElementHandleChannel, diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 8204b8da4b..cde1894811 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -1746,6 +1746,7 @@ Frame: - detached - visible - hidden + omitReturnValue: boolean? returns: element: ElementHandle? tracing: diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index e7e6c3ce6a..4de5ccd2b2 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -881,6 +881,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { strict: tOptional(tBoolean), timeout: tOptional(tNumber), state: tOptional(tEnum(['attached', 'detached', 'visible', 'hidden'])), + omitReturnValue: tOptional(tBoolean), }); scheme.FrameExpectParams = tObject({ selector: tString, diff --git a/src/server/dom.ts b/src/server/dom.ts index ab04530953..9599c11625 100644 --- a/src/server/dom.ts +++ b/src/server/dom.ts @@ -770,7 +770,7 @@ export class ElementHandle extends js.JSHandle { if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) throw new Error(`state: expected one of (attached|detached|visible|hidden)`); const info = this._page.parseSelector(selector, options); - const task = waitForSelectorTask(info, state, this); + const task = waitForSelectorTask(info, state, false, this); const controller = new ProgressController(metadata, this); return controller.run(async progress => { progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); @@ -939,13 +939,13 @@ function compensateHalfIntegerRoundingError(point: types.Point) { export type SchedulableTask = (injectedScript: js.JSHandle) => Promise>>; -export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden', root?: ElementHandle): SchedulableTask { - return injectedScript => injectedScript.evaluateHandle((injected, { parsed, strict, state, root }) => { +export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden', omitReturnValue?: boolean, root?: ElementHandle): SchedulableTask { + return injectedScript => injectedScript.evaluateHandle((injected, { parsed, strict, state, omitReturnValue, root }) => { let lastElement: Element | undefined; return injected.pollRaf((progress, continuePolling) => { const elements = injected.querySelectorAll(parsed, root || document); - const element = elements[0]; + let element: Element | undefined = elements[0]; const visible = element ? injected.isVisible(element) : false; if (lastElement !== element) { @@ -962,18 +962,22 @@ export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | } } + const hasElement = !!element; + if (omitReturnValue) + element = undefined; + switch (state) { case 'attached': - return element ? element : continuePolling; + return hasElement ? element : continuePolling; case 'detached': - return !element ? undefined : continuePolling; + return !hasElement ? undefined : continuePolling; case 'visible': return visible ? element : continuePolling; case 'hidden': return !visible ? undefined : continuePolling; } }); - }, { parsed: selector.parsed, strict: selector.strict, state, root }); + }, { parsed: selector.parsed, strict: selector.strict, state, omitReturnValue, root }); } export const kUnableToAdoptErrorMessage = 'Unable to adopt element handle from a different document'; diff --git a/src/server/frames.ts b/src/server/frames.ts index 3e99bff193..dd23064898 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -704,7 +704,7 @@ export class Frame extends SdkObject { return this._page.selectors.query(this, selector, options); } - async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions = {}): Promise | null> { + async waitForSelector(metadata: CallMetadata, selector: string, options: types.WaitForElementOptions & { omitReturnValue?: boolean } = {}): Promise | null> { const controller = new ProgressController(metadata, this); if ((options as any).visibility) throw new Error('options.visibility is not supported, did you mean options.state?'); @@ -714,7 +714,7 @@ export class Frame extends SdkObject { if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) throw new Error(`state: expected one of (attached|detached|visible|hidden)`); const info = this._page.parseSelector(selector, options); - const task = dom.waitForSelectorTask(info, state); + const task = dom.waitForSelectorTask(info, state, options.omitReturnValue); return controller.run(async progress => { progress.log(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); while (progress.isRunning()) { diff --git a/tests/page/locator-misc-2.spec.ts b/tests/page/locator-misc-2.spec.ts index d0a582633a..614a444cd8 100644 --- a/tests/page/locator-misc-2.spec.ts +++ b/tests/page/locator-misc-2.spec.ts @@ -82,3 +82,20 @@ it('should return bounding box', async ({ page, server, browserName, headless, i const box = await element.boundingBox(); expect(box).toEqual({ x: 100, y: 50, width: 50, height: 50 }); }); + +it('should waitFor', async ({ page }) => { + await page.setContent(`
`); + const locator = page.locator('span'); + const promise = locator.waitFor(); + await page.$eval('div', div => div.innerHTML = 'target'); + await promise; + await expect(locator).toHaveText('target'); +}); + +it('should waitFor hidden', async ({ page }) => { + await page.setContent(`
target
`); + const locator = page.locator('span'); + const promise = locator.waitFor({ state: 'hidden' }); + await page.$eval('div', div => div.innerHTML = ''); + await promise; +}); diff --git a/types/types.d.ts b/types/types.d.ts index 85cf211f82..c0c5d6211c 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -9551,6 +9551,40 @@ export interface Locator { * `false`. Useful to wait until the element is ready for the action without performing it. */ trial?: boolean; + }): Promise; + + /** + * Returns when element specified by locator satisfies the `state` option. + * + * If target element already satisfies the condition, the method returns immediately. Otherwise, waits for up to `timeout` + * milliseconds until the condition is met. + * + * ```js + * const orderSent = page.locator('#order-sent'); + * await orderSent.waitFor(); + * ``` + * + * @param options + */ + waitFor(options?: { + /** + * Defaults to `'visible'`. Can be either: + * - `'attached'` - wait for element to be present in DOM. + * - `'detached'` - wait for element to not be present in DOM. + * - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element without + * any content or with `display:none` has an empty bounding box and is not considered visible. + * - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or `visibility:hidden`. + * This is opposite to the `'visible'` option. + */ + state?: "attached"|"detached"|"visible"|"hidden"; + + /** + * Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by + * using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; }): Promise;} /**