From 817836c83685ab266fb8d283ae3694f25ce80b15 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 16 Dec 2019 12:24:25 -0800 Subject: [PATCH] feature(click): option to wait for 'stationary' or 'hittarget' --- src/dom.ts | 71 ++++++++++++++++++++-- src/firefox/FrameManager.ts | 2 +- src/frames.ts | 48 +++++++-------- src/input.ts | 2 +- src/types.ts | 10 ++++ test/assets/input/button.html | 43 ++++++++++++++ test/click.spec.js | 107 ++++++++++++++++++++++++++++++++++ utils/testserver/index.js | 5 ++ 8 files changed, 254 insertions(+), 34 deletions(-) diff --git a/src/dom.ts b/src/dom.ts index e6cc789ad0..6254692538 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -12,6 +12,7 @@ import * as zsSelectorEngineSource from './generated/zsSelectorEngineSource'; import { assert, helper, debugError } from './helper'; import Injected from './injected/injected'; import { Page } from './page'; +import { TimeoutError } from './errors'; type ScopedSelector = types.Selector & { scope?: ElementHandle }; type ResolvedSelector = { scope?: ElementHandle, selector: string, visibility: types.Visibility, disposeScope?: boolean }; @@ -279,8 +280,66 @@ export class ElementHandle extends js.JSHandle { return { point, scrollX, scrollY }; } - async _performPointerAction(action: (point: types.Point) => Promise, options?: input.PointerActionOptions): Promise { + private async _waitForStationary(options: types.TimeoutOptions) { + const { timeout = this._page._timeoutSettings.timeout() } = options; + const success = await helper.waitWithTimeout(this.evaluate((node: Node, injected: Injected, timeout: number) => { + let elementState; + return injected.pollRaf(() => { + if (!node.isConnected || node.nodeType !== Node.ELEMENT_NODE) + return false; + const element = node as Element; + const rect = element.getBoundingClientRect(); + let computedOpacity = 1; + for (let parent: Element | undefined = element; parent; parent = injected.utils.parentElementOrShadowHost(parent)) + computedOpacity *= +getComputedStyle(parent).opacity; + const newState = { + x: rect.top, + y: rect.left, + width: rect.width, + height: rect.height, + computedOpacity, + iteration: elementState ? elementState.iteration + 1 : 1, + }; + if (elementState && + elementState.iteration >= 2 && + newState.x === elementState.x && + newState.y === elementState.y && + newState.width === elementState.width && + newState.height === elementState.height && + newState.computedOpacity === elementState.computedOpacity) { + return true; + } + elementState = newState; + return false; + }, timeout); + }, await this._context._injected(), timeout), 'stationary', timeout); + if (!success) + throw new TimeoutError(`waiting for stationary failed: timeout ${timeout}ms exceeded`); + } + + private async _waitToBecomeHitTargetAt(point: types.Point, options: types.TimeoutOptions) { + const { timeout = this._page._timeoutSettings.timeout() } = options; + const success = await helper.waitWithTimeout(this.evaluate((node: Node, injected: Injected, timeout: number, point: types.Point) => { + return injected.pollRaf(() => { + for (let hitElement = injected.utils.deepElementFromPoint(document, point.x, point.y); + hitElement; + hitElement = injected.utils.parentElementOrShadowHost(hitElement)) { + if (hitElement === node) + return true; + } + return false; + }, timeout); + }, await this._context._injected(), timeout, point), 'hit target', timeout); + if (!success) + throw new TimeoutError(`waiting for hit target failed: timeout ${timeout}ms exceeded`); + } + + async _performPointerAction(action: (point: types.Point) => Promise, options?: input.PointerActionOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise { + if (options && types.multipleContains(options.waitFor, 'stationary')) + await this._waitForStationary(options); const point = await this._ensurePointerActionPoint(options ? options.relativePoint : undefined); + if (options && types.multipleContains(options.waitFor, 'hittarget')) + await this._waitToBecomeHitTargetAt(point, options); let restoreModifiers: input.Modifier[] | undefined; if (options && options.modifiers) restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); @@ -289,19 +348,19 @@ export class ElementHandle extends js.JSHandle { await this._page.keyboard._ensureModifiers(restoreModifiers); } - hover(options?: input.PointerActionOptions): Promise { + hover(options?: input.PointerActionOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise { return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options); } - click(options?: input.ClickOptions): Promise { + click(options?: input.ClickOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise { return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options); } - dblclick(options?: input.MultiClickOptions): Promise { + dblclick(options?: input.MultiClickOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise { return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options); } - tripleclick(options?: input.MultiClickOptions): Promise { + tripleclick(options?: input.MultiClickOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise { return this._performPointerAction(point => this._page.mouse.tripleclick(point.x, point.y, options), options); } @@ -350,7 +409,7 @@ export class ElementHandle extends js.JSHandle { throw new Error(errorMessage); } - async type(text: string, options: { delay: (number | undefined); } | undefined) { + async type(text: string, options?: { delay?: number }) { await this.focus(); await this._page.keyboard.type(text, options); } diff --git a/src/firefox/FrameManager.ts b/src/firefox/FrameManager.ts index f7fdc015bd..12136d5cd8 100644 --- a/src/firefox/FrameManager.ts +++ b/src/firefox/FrameManager.ts @@ -466,7 +466,7 @@ export class FrameManager extends EventEmitter implements PageDelegate { } } -export function normalizeWaitUntil(waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[]): frames.LifecycleEvent[] { +export function normalizeWaitUntil(waitUntil: types.Multiple): frames.LifecycleEvent[] { if (!Array.isArray(waitUntil)) waitUntil = [waitUntil]; for (const condition of waitUntil) { diff --git a/src/frames.ts b/src/frames.ts index 1d0a89eecd..09e902b2b6 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -39,7 +39,7 @@ type ContextData = { export type NavigateOptions = { timeout?: number, - waitUntil?: LifecycleEvent | LifecycleEvent[], + waitUntil?: types.Multiple, }; export type GotoOptions = NavigateOptions & { @@ -48,8 +48,6 @@ export type GotoOptions = NavigateOptions & { export type LifecycleEvent = 'load' | 'domcontentloaded'; -export type WaitForOptions = types.TimeoutOptions & { waitFor?: boolean }; - export class Frame { _id: string; readonly _firedLifecycleEvents: Set; @@ -308,43 +306,43 @@ export class Frame { return result; } - async click(selector: string | types.Selector, options?: WaitForOptions & ClickOptions) { - const handle = await this._optionallyWaitForInUtilityContext(selector, options); - await handle.click(options); + async click(selector: string | types.Selector, options?: ClickOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) { + const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options)); + await handle.click(types.toWaitFor(options)); await handle.dispose(); } - async dblclick(selector: string | types.Selector, options?: WaitForOptions & MultiClickOptions) { - const handle = await this._optionallyWaitForInUtilityContext(selector, options); - await handle.dblclick(options); + async dblclick(selector: string | types.Selector, options?: MultiClickOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) { + const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options)); + await handle.dblclick(types.toWaitFor(options)); await handle.dispose(); } - async tripleclick(selector: string | types.Selector, options?: WaitForOptions & MultiClickOptions) { - const handle = await this._optionallyWaitForInUtilityContext(selector, options); - await handle.tripleclick(options); + async tripleclick(selector: string | types.Selector, options?: MultiClickOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) { + const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options)); + await handle.tripleclick(types.toWaitFor(options)); await handle.dispose(); } - async fill(selector: string | types.Selector, value: string, options?: WaitForOptions) { + async fill(selector: string | types.Selector, value: string, options?: types.WaitForOptions<'selector'>) { const handle = await this._optionallyWaitForInUtilityContext(selector, options); await handle.fill(value); await handle.dispose(); } - async focus(selector: string | types.Selector, options?: WaitForOptions) { + async focus(selector: string | types.Selector, options?: types.WaitForOptions<'selector'>) { const handle = await this._optionallyWaitForInUtilityContext(selector, options); await handle.focus(); await handle.dispose(); } - async hover(selector: string | types.Selector, options?: WaitForOptions & PointerActionOptions) { - const handle = await this._optionallyWaitForInUtilityContext(selector, options); - await handle.hover(options); + async hover(selector: string | types.Selector, options?: PointerActionOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) { + const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options)); + await handle.hover(types.toWaitFor(options)); await handle.dispose(); } - async select(selector: string | types.Selector, value: string | dom.ElementHandle | SelectOption | string[] | dom.ElementHandle[] | SelectOption[] | undefined, options?: WaitForOptions): Promise { + async select(selector: string | types.Selector, value: types.Multiple | undefined, options?: types.WaitForOptions<'selector'>): Promise { const handle = await this._optionallyWaitForInUtilityContext(selector, options); const toDispose: Promise[] = []; const values = value === undefined ? [] : value instanceof Array ? value : [value]; @@ -363,10 +361,8 @@ export class Frame { return result; } - async type(selector: string | types.Selector, text: string, options: WaitForOptions & { delay: (number | undefined); } | undefined) { - const context = await this._utilityContext(); - const handle = await context._$(types.clearSelector(selector)); - assert(handle, 'No node found for selector: ' + types.selectorToString(selector)); + async type(selector: string | types.Selector, text: string, options?: { delay?: number } & types.WaitForOptions<'selector'>) { + const handle = await this._optionallyWaitForInUtilityContext(selector, options); await handle.type(text, options); await handle.dispose(); } @@ -381,13 +377,13 @@ export class Frame { return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout))); } - private async _optionallyWaitForInUtilityContext(selector: string | types.Selector,options: WaitForOptions): Promise { + private async _optionallyWaitForInUtilityContext(selector: string | types.Selector, options?: types.WaitForOptions<'selector'>): Promise { let handle: dom.ElementHandle | null; - if (options && options.waitFor) { + if (options && types.multipleContains(options.waitFor, 'selector')) { handle = await this._waitForSelectorInUtilityContext(selector, options); } else { const context = await this._utilityContext(); - handle = await context._$(types.clearSelector(selector)); + handle = await context._$(types.clearSelector(selector)); } assert(handle, 'No node found for selector: ' + types.selectorToString(selector)); return handle; @@ -616,7 +612,7 @@ export class LifecycleWatcher { private _targetUrl?: string; private _expectedDocumentId?: string; - constructor(frame: Frame, waitUntil: LifecycleEvent | LifecycleEvent[], timeout: number) { + constructor(frame: Frame, waitUntil: types.Multiple, timeout: number) { if (Array.isArray(waitUntil)) waitUntil = waitUntil.slice(); else if (typeof waitUntil === 'string') diff --git a/src/input.ts b/src/input.ts index 0c3b0f846c..c9c71682e8 100644 --- a/src/input.ts +++ b/src/input.ts @@ -133,7 +133,7 @@ export class Keyboard { await this._raw.sendText(text); } - async type(text: string, options: { delay: (number | undefined); } | undefined) { + async type(text: string, options?: { delay?: number }) { const delay = (options && options.delay) || null; for (const char of text) { if (keyboardLayout.keyDefinitions[char]) { diff --git a/src/types.ts b/src/types.ts index d925140fd8..2dee0b6850 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,3 +72,13 @@ export type Viewport = { isLandscape?: boolean; hasTouch?: boolean; }; + +export type Multiple = T | T[]; +export function multipleContains(multiple: Multiple | undefined, t: T): boolean { + return multiple === t || (Array.isArray(multiple) && multiple.includes(t)); +} + +export type WaitForOptions = TimeoutOptions & { waitFor?: Multiple }; +export function toWaitFor(o: WaitForOptions): A extends B ? WaitForOptions : never { + return o as any as (A extends B ? WaitForOptions : never); +} diff --git a/test/assets/input/button.html b/test/assets/input/button.html index aaba6a5e2a..bbb4280e26 100644 --- a/test/assets/input/button.html +++ b/test/assets/input/button.html @@ -2,10 +2,43 @@ Button test + +
\ No newline at end of file diff --git a/test/click.spec.js b/test/click.spec.js index 4e26fb0bc9..4559de0290 100644 --- a/test/click.spec.js +++ b/test/click.spec.js @@ -354,5 +354,112 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME await page.click('button'); expect(await page.evaluate('window.clicked')).toBe(true); }); + + describe('wait for option', () => { + it('should wait for selector', async({page, server}) => { + let clicked = false; + const clickPromise = page.click('button', { waitFor: 'selector' }).then(() => clicked = true); + expect(clicked).toBe(false); + await page.goto(server.EMPTY_PAGE); + expect(clicked).toBe(false); + await page.goto(server.PREFIX + '/input/button.html'); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + + it('should wait for hit target', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html?glasspane'); + let clicked = false; + const clickPromise = page.click('button', { waitFor: 'hittarget' }).then(() => clicked = true); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => 'dummy'); + expect(clicked).toBe(false); + await page.evaluate(() => toggleGlassPane()); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + + it('should wait for stationary', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html?moving'); + let clicked = false; + const clickPromise = page.click('button', { waitFor: 'stationary' }).then(() => clicked = true); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(f))); + expect(clicked).toBe(false); + await page.evaluate(() => toggleMoving()); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + + it('should wait for selector and hit target', async({page, server}) => { + let clicked = false; + const clickPromise = page.click('button', { waitFor: ['hittarget', 'selector'] }).then(() => clicked = true); + expect(clicked).toBe(false); + await page.goto(server.EMPTY_PAGE); + expect(clicked).toBe(false); + await page.goto(server.PREFIX + '/input/button.html?glasspane'); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => 'dummy'); + expect(clicked).toBe(false); + await page.evaluate(() => toggleGlassPane()); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + + it('should wait for selector and stationary', async({page, server}) => { + let clicked = false; + const clickPromise = page.click('button', { waitFor: ['stationary', 'selector'] }).then(() => clicked = true); + expect(clicked).toBe(false); + await page.goto(server.EMPTY_PAGE); + expect(clicked).toBe(false); + await page.goto(server.PREFIX + '/input/button.html?moving'); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(f))); + expect(clicked).toBe(false); + await page.evaluate(() => toggleMoving()); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + + it('should wait for stationary and hit target', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html?moving&glasspane'); + let clicked = false; + const clickPromise = page.click('button', { waitFor: ['stationary', 'hittarget'] }).then(() => clicked = true); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(f))); + expect(clicked).toBe(false); + await page.evaluate(() => toggleMoving()); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(f))); + expect(clicked).toBe(false); + await page.evaluate(() => toggleGlassPane()); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + + it('should wait for hit target and stationary', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html?moving&glasspane'); + let clicked = false; + const clickPromise = page.click('button', { waitFor: ['stationary', 'hittarget'] }).then(() => clicked = true); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(f))); + expect(clicked).toBe(false); + await page.evaluate(() => toggleGlassPane()); + expect(clicked).toBe(false); + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(f))); + expect(clicked).toBe(false); + await page.evaluate(() => toggleMoving()); + await clickPromise; + expect(await page.evaluate(() => result)).toBe('Clicked'); + }); + }); }); }; diff --git a/utils/testserver/index.js b/utils/testserver/index.js index 85f2d1fdd7..86bee72d53 100644 --- a/utils/testserver/index.js +++ b/utils/testserver/index.js @@ -226,6 +226,11 @@ class TestServer { serveFile(request, response, pathName) { if (pathName === '/') pathName = '/index.html'; + try { + const url = new URL('http://localhost' + pathName); + pathName = url.pathname; + } catch (e) { + } const filePath = path.join(this._dirPath, pathName.substring(1)); if (this._cachedPathPrefix !== null && filePath.startsWith(this._cachedPathPrefix)) {