diff --git a/docs/api.md b/docs/api.md index cb907e4560..555ae47dee 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,7 @@ - [class: FileChooser](#class-filechooser) - [class: Keyboard](#class-keyboard) - [class: Mouse](#class-mouse) +- [class: Touchscreen](#class-touchscreen) - [class: Request](#class-request) - [class: Response](#class-response) - [class: Selectors](#class-selectors) @@ -782,8 +783,10 @@ page.removeListener('request', logRequest); - [page.setExtraHTTPHeaders(headers)](#pagesetextrahttpheadersheaders) - [page.setInputFiles(selector, files[, options])](#pagesetinputfilesselector-files-options) - [page.setViewportSize(viewportSize)](#pagesetviewportsizeviewportsize) +- [page.tap(selector[, options])](#pagetapselector-options) - [page.textContent(selector[, options])](#pagetextcontentselector-options) - [page.title()](#pagetitle) +- [page.touchscreen](#pagetouchscreen) - [page.type(selector, text[, options])](#pagetypeselector-text-options) - [page.uncheck(selector, [options])](#pageuncheckselector-options) - [page.unroute(url[, handler])](#pageunrouteurl-handler) @@ -1833,6 +1836,31 @@ await page.setViewportSize({ await page.goto('https://example.com'); ``` +#### page.tap(selector[, options]) +- `selector` <[string]> A selector to search for element to tap. If there are multiple elements satisfying the selector, the first will be tapped. See [working with selectors](#working-with-selectors) for more details. +- `options` <[Object]> + - `position` <[Object]> A point to tap relative to the top-left corner of element padding box. If not specified, taps some visible point of the element. + - `x` <[number]> + - `y` <[number]> + - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the tap, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `noWaitAfter` <[boolean]> Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. Defaults to `false`. + - `force` <[boolean]> Whether to bypass the [actionability](./actionability.md) checks. Defaults to `false`. + - `timeout` <[number]> 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)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods. +- returns: <[Promise]> Promise that resolves when the element matching `selector` is successfully tapped. + +This method taps an element matching `selector` by performing the following steps: +1. Find an element match matching `selector`. If there is none, wait until a matching element is attached to the DOM. +1. Wait for [actionability](./actionability.md) checks on the matched element, unless `force` option is set. If the element is detached during the checks, the whole action is retried. +1. Scroll the element into view if needed. +1. Use [page.touchscreen](#pagemouse) to tap the center of the element, or the specified `position`. +1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + +When all steps combined have not finished during the specified `timeout`, this method rejects with a [TimeoutError]. Passing zero timeout disables this. + +> **NOTE** `page.tap()` requires that the `hasTouch` option of the browser context be set to true. + +Shortcut for [page.mainFrame().tap()](#framename). + #### page.textContent(selector[, options]) - `selector` <[string]> A selector to search for an element. If there are multiple elements satisfying the selector, the first will be picked. See [working with selectors](#working-with-selectors) for more details. - `options` <[Object]> @@ -1841,13 +1869,14 @@ await page.goto('https://example.com'); Resolves to the `element.textContent`. - #### page.title() - returns: <[Promise]<[string]>> The page's title. Shortcut for [page.mainFrame().title()](#frametitle). +#### page.touchscreen +- returns: <[Touchscreen]> #### page.type(selector, text[, options]) - `selector` <[string]> A selector of an element to type into. If there are multiple elements satisfying the selector, the first will be used. See [working with selectors](#working-with-selectors) for more details. @@ -2154,6 +2183,7 @@ console.log(text); - [frame.selectOption(selector, values[, options])](#frameselectoptionselector-values-options) - [frame.setContent(html[, options])](#framesetcontenthtml-options) - [frame.setInputFiles(selector, files[, options])](#framesetinputfilesselector-files-options) +- [frame.tap(selector[, options])](#frametapselector-options) - [frame.textContent(selector[, options])](#frametextcontentselector-options) - [frame.title()](#frametitle) - [frame.type(selector, text[, options])](#frametypeselector-text-options) @@ -2594,6 +2624,29 @@ This method expects `selector` to point to an [input element](https://developer. Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they are resolved relative to the [current working directory](https://nodejs.org/api/process.html#process_process_cwd). For empty array, clears the selected files. +#### frame.tap(selector[, options]) +- `selector` <[string]> A selector to search for element to tap. If there are multiple elements satisfying the selector, the first will be tapped. See [working with selectors](#working-with-selectors) for more details. +- `options` <[Object]> + - `position` <[Object]> A point to tap relative to the top-left corner of element padding box. If not specified, taps some visible point of the element. + - `x` <[number]> + - `y` <[number]> + - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the tap, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `noWaitAfter` <[boolean]> Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. Defaults to `false`. + - `force` <[boolean]> Whether to bypass the [actionability](./actionability.md) checks. Defaults to `false`. + - `timeout` <[number]> 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)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods. +- returns: <[Promise]> Promise that resolves when the element matching `selector` is successfully tapped. + +This method taps an element matching `selector` by performing the following steps: +1. Find an element match matching `selector`. If there is none, wait until a matching element is attached to the DOM. +1. Wait for [actionability](./actionability.md) checks on the matched element, unless `force` option is set. If the element is detached during the checks, the whole action is retried. +1. Scroll the element into view if needed. +1. Use [page.touchscreen](#pagemouse) to tap the center of the element, or the specified `position`. +1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + +When all steps combined have not finished during the specified `timeout`, this method rejects with a [TimeoutError]. Passing zero timeout disables this. + +> **NOTE** `frame.tap()` requires that the `hasTouch` option of the browser context be set to true. + #### frame.textContent(selector[, options]) - `selector` <[string]> A selector to search for an element. If there are multiple elements satisfying the selector, the first will be picked. See [working with selectors](#working-with-selectors) for more details. - `options` <[Object]> @@ -2602,7 +2655,6 @@ Sets the value of the file input to these file paths or files. If some of the `f Resolves to the `element.textContent`. - #### frame.title() - returns: <[Promise]<[string]>> The page's title. @@ -2800,6 +2852,7 @@ ElementHandle instances can be used as an argument in [`page.$eval()`](#pageeval - [elementHandle.selectOption(values[, options])](#elementhandleselectoptionvalues-options) - [elementHandle.selectText([options])](#elementhandleselecttextoptions) - [elementHandle.setInputFiles(files[, options])](#elementhandlesetinputfilesfiles-options) +- [elementHandle.tap([options])](#elementhandletapoptions) - [elementHandle.textContent()](#elementhandletextcontent) - [elementHandle.toString()](#elementhandletostring) - [elementHandle.type(text[, options])](#elementhandletypetext-options) @@ -3119,6 +3172,29 @@ This method expects `elementHandle` to point to an [input element](https://devel Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then they are resolved relative to the [current working directory](https://nodejs.org/api/process.html#process_process_cwd). For empty array, clears the selected files. +#### elementHandle.tap([options]) +- `options` <[Object]> + - `position` <[Object]> A point to tap relative to the top-left corner of element padding box. If not specified, taps some visible point of the element. + - `x` <[number]> + - `y` <[number]> + - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the tap, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `force` <[boolean]> Whether to bypass the [actionability](./actionability.md) checks. Defaults to `false`. + - `noWaitAfter` <[boolean]> Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. Defaults to `false`. + - `timeout` <[number]> 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)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods. +- returns: <[Promise]> Promise that resolves when the element is successfully tapped. + +This method taps the element by performing the following steps: +1. Wait for [actionability](./actionability.md) checks on the element, unless `force` option is set. +1. Scroll the element into view if needed. +1. Use [page.touchscreen](#pagemouse) to tap in the center of the element, or the specified `position`. +1. Wait for initiated navigations to either succeed or fail, unless `noWaitAfter` option is set. + +If the element is detached from the DOM at any moment during the action, this method rejects. + +When all steps combined have not finished during the specified `timeout`, this method rejects with a [TimeoutError]. Passing zero timeout disables this. + +> **NOTE** `elementHandle.tap()` requires that the `hasTouch` option of the browser context be set to true. + #### elementHandle.textContent() - returns: <[Promise]<[null]|[string]>> Resolves to the `node.textContent`. @@ -3701,6 +3777,17 @@ Dispatches a `mousemove` event. Dispatches a `mouseup` event. +### class: Touchscreen + +The Touchscreen class operates in main-frame CSS pixels relative to the top-left corner of the viewport. Methods on the +touchscreen can only be used in browser contexts that have been intialized with `hasTouch` set to true. + +#### touchscreen.tap(x, y) +- `x` <[number]> +- `y` <[number]> +- returns: <[Promise]> + +Dispatches a `touchstart` and `touchend` event with a single touch at the position (`x`,`y`). ### class: Request @@ -4839,6 +4926,7 @@ const { chromium } = require('playwright'); [Selectors]: #class-selectors "Selectors" [Serializable]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Description "Serializable" [TimeoutError]: #class-timeouterror "TimeoutError" +[Touchscreen]: #class-touchscreen "Touchscreen" [UIEvent.detail]: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail "UIEvent.detail" [URL]: https://nodejs.org/api/url.html [USKeyboardLayout]: ../src/usKeyboardLayout.ts "USKeyboardLayout" diff --git a/src/client/api.ts b/src/client/api.ts index 9364a09470..306dd50117 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -27,7 +27,7 @@ export { FileChooser } from './fileChooser'; export { Logger } from './types'; export { TimeoutError } from '../utils/errors'; export { Frame } from './frame'; -export { Keyboard, Mouse } from './input'; +export { Keyboard, Mouse, Touchscreen } from './input'; export { JSHandle } from './jsHandle'; export { Request, Response, Route } from './network'; export { Page } from './page'; diff --git a/src/client/elementHandle.ts b/src/client/elementHandle.ts index e0e7060455..4b4af6dfc3 100644 --- a/src/client/elementHandle.ts +++ b/src/client/elementHandle.ts @@ -115,6 +115,12 @@ export class ElementHandle extends JSHandle { }); } + async tap(options: channels.ElementHandleTapOptions = {}): Promise { + return this._wrapApiCall('elementHandle.tap', async () => { + return await this._elementChannel.tap(options); + }); + } + async selectOption(values: string | ElementHandle | SelectOption | string[] | ElementHandle[] | SelectOption[] | null, options: SelectOptionOptions = {}): Promise { return this._wrapApiCall('elementHandle.selectOption', async () => { const result = await this._elementChannel.selectOption({ ...convertSelectOptionValues(values), ...options }); diff --git a/src/client/frame.ts b/src/client/frame.ts index a823af1490..f0ba18b6fd 100644 --- a/src/client/frame.ts +++ b/src/client/frame.ts @@ -322,6 +322,12 @@ export class Frame extends ChannelOwner { + return await this._channel.tap({ selector, ...options }); + }); + } + async fill(selector: string, value: string, options: channels.FrameFillOptions = {}) { return this._wrapApiCall(this._apiName('fill'), async () => { return await this._channel.fill({ selector, value, ...options }); diff --git a/src/client/input.ts b/src/client/input.ts index 668b08a5b4..ef0a567415 100644 --- a/src/client/input.ts +++ b/src/client/input.ts @@ -72,3 +72,15 @@ export class Mouse { await this.click(x, y, { ...options, clickCount: 2 }); } } + +export class Touchscreen { + private _channel: channels.PageChannel; + + constructor(channel: channels.PageChannel) { + this._channel = channel; + } + + async tap(x: number, y: number) { + await this._channel.touchscreenTap({x, y}); + } +} diff --git a/src/client/page.ts b/src/client/page.ts index acaa9a7631..a0e9b70dce 100644 --- a/src/client/page.ts +++ b/src/client/page.ts @@ -29,7 +29,7 @@ import { Download } from './download'; import { ElementHandle, determineScreenshotType } from './elementHandle'; import { Worker } from './worker'; import { Frame, verifyLoadState, WaitForNavigationOptions } from './frame'; -import { Keyboard, Mouse } from './input'; +import { Keyboard, Mouse, Touchscreen } from './input'; import { assertMaxArguments, Func1, FuncOn, SmartHandle, serializeArgument, parseResult, JSHandle } from './jsHandle'; import { Request, Response, Route, RouteHandler, validateHeaders } from './network'; import { FileChooser } from './fileChooser'; @@ -77,6 +77,7 @@ export class Page extends ChannelOwner Promise; @@ -102,6 +103,7 @@ export class Page extends ChannelOwner this._mainFrame.dblclick(selector, options)); } + async tap(selector: string, options?: channels.FrameTapOptions) { + return this._attributeToPage(() => this._mainFrame.tap(selector, options)); + } + async fill(selector: string, value: string, options?: channels.FrameFillOptions) { return this._attributeToPage(() => this._mainFrame.fill(selector, value, options)); } diff --git a/src/dispatchers/elementHandlerDispatcher.ts b/src/dispatchers/elementHandlerDispatcher.ts index f74d19b29d..1baddfc138 100644 --- a/src/dispatchers/elementHandlerDispatcher.ts +++ b/src/dispatchers/elementHandlerDispatcher.ts @@ -92,6 +92,12 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements chann }, { ...metadata, type: 'dblclick', target: this._elementHandle, page: this._elementHandle._page }); } + async tap(params: channels.ElementHandleTapParams, metadata?: channels.Metadata): Promise { + return runAction(async controller => { + return await this._elementHandle.tap(controller, params); + }, { ...metadata, type: 'tap', target: this._elementHandle, page: this._elementHandle._page }); + } + async selectOption(params: channels.ElementHandleSelectOptionParams, metadata?: channels.Metadata): Promise { return runAction(async controller => { const elements = (params.elements || []).map(e => (e as ElementHandleDispatcher)._elementHandle); diff --git a/src/dispatchers/frameDispatcher.ts b/src/dispatchers/frameDispatcher.ts index b2bcbb809b..0f7ead67c4 100644 --- a/src/dispatchers/frameDispatcher.ts +++ b/src/dispatchers/frameDispatcher.ts @@ -125,6 +125,12 @@ export class FrameDispatcher extends Dispatcher { + return runAction(async controller => { + return await this._frame.tap(controller, params.selector, params); + }, { ...metadata, type: 'tap', target: params.selector, page: this._frame._page }); + } + async fill(params: channels.FrameFillParams, metadata?: channels.Metadata): Promise { return runAction(async controller => { return await this._frame.fill(controller, params.selector, params.value, params); diff --git a/src/dispatchers/pageDispatcher.ts b/src/dispatchers/pageDispatcher.ts index d9de32ead0..fabbe86af9 100644 --- a/src/dispatchers/pageDispatcher.ts +++ b/src/dispatchers/pageDispatcher.ts @@ -185,6 +185,10 @@ export class PageDispatcher extends Dispatcher i await this._page.mouse.click(params.x, params.y, params); } + async touchscreenTap(params: channels.PageTouchscreenTapParams): Promise { + await this._page.touchscreen.tap(params.x, params.y); + } + async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams): Promise { const rootAXNode = await this._page.accessibility.snapshot({ interestingOnly: params.interestingOnly, diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index 65cf1ad7e5..116963d11f 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -710,6 +710,7 @@ export interface PageChannel extends Channel { mouseDown(params: PageMouseDownParams, metadata?: Metadata): Promise; mouseUp(params: PageMouseUpParams, metadata?: Metadata): Promise; mouseClick(params: PageMouseClickParams, metadata?: Metadata): Promise; + touchscreenTap(params: PageTouchscreenTapParams, metadata?: Metadata): Promise; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: Metadata): Promise; pdf(params: PagePdfParams, metadata?: Metadata): Promise; crStartJSCoverage(params: PageCrStartJSCoverageParams, metadata?: Metadata): Promise; @@ -996,6 +997,14 @@ export type PageMouseClickOptions = { clickCount?: number, }; export type PageMouseClickResult = void; +export type PageTouchscreenTapParams = { + x: number, + y: number, +}; +export type PageTouchscreenTapOptions = { + +}; +export type PageTouchscreenTapResult = void; export type PageAccessibilitySnapshotParams = { interestingOnly?: boolean, root?: ElementHandleChannel, @@ -1133,6 +1142,7 @@ export interface FrameChannel extends Channel { selectOption(params: FrameSelectOptionParams, metadata?: Metadata): Promise; setContent(params: FrameSetContentParams, metadata?: Metadata): Promise; setInputFiles(params: FrameSetInputFilesParams, metadata?: Metadata): Promise; + tap(params: FrameTapParams, metadata?: Metadata): Promise; textContent(params: FrameTextContentParams, metadata?: Metadata): Promise; title(params?: FrameTitleParams, metadata?: Metadata): Promise; type(params: FrameTypeParams, metadata?: Metadata): Promise; @@ -1475,6 +1485,28 @@ export type FrameSetInputFilesOptions = { noWaitAfter?: boolean, }; export type FrameSetInputFilesResult = void; +export type FrameTapParams = { + selector: string, + force?: boolean, + noWaitAfter?: boolean, + modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], + position?: { + x: number, + y: number, + }, + timeout?: number, +}; +export type FrameTapOptions = { + force?: boolean, + noWaitAfter?: boolean, + modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], + position?: { + x: number, + y: number, + }, + timeout?: number, +}; +export type FrameTapResult = void; export type FrameTextContentParams = { selector: string, timeout?: number, @@ -1675,6 +1707,7 @@ export interface ElementHandleChannel extends JSHandleChannel { selectOption(params: ElementHandleSelectOptionParams, metadata?: Metadata): Promise; selectText(params: ElementHandleSelectTextParams, metadata?: Metadata): Promise; setInputFiles(params: ElementHandleSetInputFilesParams, metadata?: Metadata): Promise; + tap(params: ElementHandleTapParams, metadata?: Metadata): Promise; textContent(params?: ElementHandleTextContentParams, metadata?: Metadata): Promise; type(params: ElementHandleTypeParams, metadata?: Metadata): Promise; uncheck(params: ElementHandleUncheckParams, metadata?: Metadata): Promise; @@ -1944,6 +1977,27 @@ export type ElementHandleSetInputFilesOptions = { noWaitAfter?: boolean, }; export type ElementHandleSetInputFilesResult = void; +export type ElementHandleTapParams = { + force?: boolean, + noWaitAfter?: boolean, + modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], + position?: { + x: number, + y: number, + }, + timeout?: number, +}; +export type ElementHandleTapOptions = { + force?: boolean, + noWaitAfter?: boolean, + modifiers?: ('Alt' | 'Control' | 'Meta' | 'Shift')[], + position?: { + x: number, + y: number, + }, + timeout?: number, +}; +export type ElementHandleTapResult = void; export type ElementHandleTextContentParams = {}; export type ElementHandleTextContentOptions = {}; export type ElementHandleTextContentResult = { diff --git a/src/protocol/protocol.yml b/src/protocol/protocol.yml index 3127d6df99..7cb6ab2b0a 100644 --- a/src/protocol/protocol.yml +++ b/src/protocol/protocol.yml @@ -775,6 +775,11 @@ Page: - middle clickCount: number? + touchscreenTap: + parameters: + x: number + y: number + accessibilitySnapshot: parameters: interestingOnly: boolean? @@ -1230,6 +1235,27 @@ Frame: timeout: number? noWaitAfter: boolean? + tap: + parameters: + selector: string + force: boolean? + noWaitAfter: boolean? + modifiers: + type: array? + items: + type: enum + literals: + - Alt + - Control + - Meta + - Shift + position: + type: object? + properties: + x: number + y: number + timeout: number? + textContent: parameters: selector: string @@ -1626,6 +1652,26 @@ ElementHandle: timeout: number? noWaitAfter: boolean? + tap: + parameters: + force: boolean? + noWaitAfter: boolean? + modifiers: + type: array? + items: + type: enum + literals: + - Alt + - Control + - Meta + - Shift + position: + type: object? + properties: + x: number + y: number + timeout: number? + textContent: returns: value: string? diff --git a/src/protocol/validator.ts b/src/protocol/validator.ts index a7de0e639f..4b09e58b52 100644 --- a/src/protocol/validator.ts +++ b/src/protocol/validator.ts @@ -404,6 +404,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { button: tOptional(tEnum(['left', 'right', 'middle'])), clickCount: tOptional(tNumber), }); + scheme.PageTouchscreenTapParams = tObject({ + x: tNumber, + y: tNumber, + }); scheme.PageAccessibilitySnapshotParams = tObject({ interestingOnly: tOptional(tBoolean), root: tOptional(tChannel('ElementHandle')), @@ -589,6 +593,17 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { timeout: tOptional(tNumber), noWaitAfter: tOptional(tBoolean), }); + scheme.FrameTapParams = tObject({ + selector: tString, + force: tOptional(tBoolean), + noWaitAfter: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), + position: tOptional(tObject({ + x: tNumber, + y: tNumber, + })), + timeout: tOptional(tNumber), + }); scheme.FrameTextContentParams = tObject({ selector: tString, timeout: tOptional(tNumber), @@ -767,6 +782,16 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { timeout: tOptional(tNumber), noWaitAfter: tOptional(tBoolean), }); + scheme.ElementHandleTapParams = tObject({ + force: tOptional(tBoolean), + noWaitAfter: tOptional(tBoolean), + modifiers: tOptional(tArray(tEnum(['Alt', 'Control', 'Meta', 'Shift']))), + position: tOptional(tObject({ + x: tNumber, + y: tNumber, + })), + timeout: tOptional(tNumber), + }); scheme.ElementHandleTextContentParams = tOptional(tObject({})); scheme.ElementHandleTypeParams = tObject({ text: tString, diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 8dd8b84477..2b1e523def 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -59,7 +59,7 @@ export class Video { } export type ActionMetadata = { - type: 'click' | 'fill' | 'dblclick' | 'hover' | 'selectOption' | 'setInputFiles' | 'type' | 'press' | 'check' | 'uncheck' | 'goto' | 'setContent' | 'goBack' | 'goForward' | 'reload', + type: 'click' | 'fill' | 'dblclick' | 'hover' | 'selectOption' | 'setInputFiles' | 'type' | 'press' | 'check' | 'uncheck' | 'goto' | 'setContent' | 'goBack' | 'goForward' | 'reload' | 'tap', page: Page, target?: dom.ElementHandle | string, value?: string, diff --git a/src/server/chromium/crInput.ts b/src/server/chromium/crInput.ts index e7e4d24b8f..44d39bb8e6 100644 --- a/src/server/chromium/crInput.ts +++ b/src/server/chromium/crInput.ts @@ -131,3 +131,27 @@ export class RawMouseImpl implements input.RawMouse { }); } } + +export class RawTouchscreenImpl implements input.RawTouchscreen { + private _client: CRSession; + + constructor(client: CRSession) { + this._client = client; + } + async tap(x: number, y: number, modifiers: Set) { + await Promise.all([ + this._client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + modifiers: toModifiersMask(modifiers), + touchPoints: [{ + x, y + }] + }), + this._client.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + modifiers: toModifiersMask(modifiers), + touchPoints: [] + }), + ]); + } +} diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index bf56a9d850..71de6f765c 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -28,7 +28,7 @@ import { toConsoleMessageLocation, exceptionToError, releaseObject } from './crP import * as dialog from '../dialog'; import { PageDelegate } from '../page'; import * as path from 'path'; -import { RawMouseImpl, RawKeyboardImpl } from './crInput'; +import { RawMouseImpl, RawKeyboardImpl, RawTouchscreenImpl } from './crInput'; import { getAccessibilityTree } from './crAccessibility'; import { CRCoverage } from './crCoverage'; import { CRPDF } from './crPdf'; @@ -49,6 +49,7 @@ export class CRPage implements PageDelegate { readonly _page: Page; readonly rawMouse: RawMouseImpl; readonly rawKeyboard: RawKeyboardImpl; + readonly rawTouchscreen: RawTouchscreenImpl; readonly _targetId: string; readonly _opener: CRPage | null; private readonly _pdf: CRPDF; @@ -69,6 +70,7 @@ export class CRPage implements PageDelegate { this._opener = opener; this.rawKeyboard = new RawKeyboardImpl(client, browserContext._browser._isMac); this.rawMouse = new RawMouseImpl(client); + this.rawTouchscreen = new RawTouchscreenImpl(client); this._pdf = new CRPDF(client); this._coverage = new CRCoverage(client); this._browserContext = browserContext; diff --git a/src/server/dom.ts b/src/server/dom.ts index 121b37abe2..bff62e055d 100644 --- a/src/server/dom.ts +++ b/src/server/dom.ts @@ -400,6 +400,17 @@ export class ElementHandle extends js.JSHandle { return this._retryPointerAction(progress, 'dblclick', true /* waitForEnabled */, point => this._page.mouse.dblclick(point.x, point.y, options), options); } + async tap(controller: ProgressController, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise { + return controller.run(async progress => { + const result = await this._tap(progress, options); + return assertDone(throwRetargetableDOMError(result)); + }, this._page._timeoutSettings.timeout(options)); + } + + _tap(progress: Progress, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> { + return this._retryPointerAction(progress, 'tap', true /* waitForEnabled */, point => this._page.touchscreen.tap(point.x, point.y), options); + } + async selectOption(controller: ProgressController, elements: ElementHandle[], values: types.SelectOption[], options: types.NavigatingActionWaitOptions): Promise { return controller.run(async progress => { const result = await this._selectOption(progress, elements, values, options); diff --git a/src/server/firefox/ffInput.ts b/src/server/firefox/ffInput.ts index 968a44a4f9..399487ec4f 100644 --- a/src/server/firefox/ffInput.ts +++ b/src/server/firefox/ffInput.ts @@ -141,3 +141,18 @@ export class RawMouseImpl implements input.RawMouse { }); } } + +export class RawTouchscreenImpl implements input.RawTouchscreen { + private _client: FFSession; + + constructor(client: FFSession) { + this._client = client; + } + async tap(x: number, y: number, modifiers: Set) { + await this._client.send('Page.dispatchTapEvent', { + x, + y, + modifiers: toModifiersMask(modifiers), + }); + } +} diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index e0a2c00b48..71e96efd69 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -27,7 +27,7 @@ import { getAccessibilityTree } from './ffAccessibility'; import { FFBrowserContext } from './ffBrowser'; import { FFSession, FFSessionEvents } from './ffConnection'; import { FFExecutionContext } from './ffExecutionContext'; -import { RawKeyboardImpl, RawMouseImpl } from './ffInput'; +import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './ffInput'; import { FFNetworkManager } from './ffNetworkManager'; import { Protocol } from './protocol'; import { rewriteErrorMessage } from '../../utils/stackTrace'; @@ -38,6 +38,7 @@ export class FFPage implements PageDelegate { readonly cspErrorsAsynchronousForInlineScipts = true; readonly rawMouse: RawMouseImpl; readonly rawKeyboard: RawKeyboardImpl; + readonly rawTouchscreen: RawTouchscreenImpl; readonly _session: FFSession; readonly _page: Page; readonly _networkManager: FFNetworkManager; @@ -55,6 +56,7 @@ export class FFPage implements PageDelegate { this._opener = opener; this.rawKeyboard = new RawKeyboardImpl(session); this.rawMouse = new RawMouseImpl(session); + this.rawTouchscreen = new RawTouchscreenImpl(session); this._contextIdToContext = new Map(); this._browserContext = browserContext; this._page = new Page(this, browserContext); diff --git a/src/server/frames.ts b/src/server/frames.ts index 0c57488376..f8ecd8b402 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -820,6 +820,12 @@ export class Frame extends EventEmitter { }, this._page._timeoutSettings.timeout(options)); } + async tap(controller: ProgressController, selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { + return controller.run(async progress => { + return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, handle => handle._tap(progress, options))); + }, this._page._timeoutSettings.timeout(options)); + } + async fill(controller: ProgressController, selector: string, value: string, options: types.NavigatingActionWaitOptions) { return controller.run(async progress => { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, handle => handle._fill(progress, value, options))); diff --git a/src/server/input.ts b/src/server/input.ts index 72ca27eb62..b9b6dd2fe3 100644 --- a/src/server/input.ts +++ b/src/server/input.ts @@ -293,3 +293,24 @@ function buildLayoutClosure(layout: keyboardLayout.KeyboardLayout): Map): Promise; +} + +export class Touchscreen { + private _raw: RawTouchscreen; + private _page: Page; + + constructor(raw: RawTouchscreen, page: Page) { + this._raw = raw; + this._page = page; + } + + async tap(x: number, y: number) { + if (!this._page._browserContext._options.hasTouch) + throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.'); + await this._raw.tap(x, y, this._page.keyboard._modifiers()); + await this._page._doSlowMo(); + } +} diff --git a/src/server/page.ts b/src/server/page.ts index fbbb92a63e..64f831d773 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -36,6 +36,7 @@ import { Selectors } from './selectors'; export interface PageDelegate { readonly rawMouse: input.RawMouse; readonly rawKeyboard: input.RawKeyboard; + readonly rawTouchscreen: input.RawTouchscreen; opener(): Promise; @@ -127,6 +128,7 @@ export class Page extends EventEmitter { readonly _browserContext: BrowserContext; readonly keyboard: input.Keyboard; readonly mouse: input.Mouse; + readonly touchscreen: input.Touchscreen; readonly _timeoutSettings: TimeoutSettings; readonly _delegate: PageDelegate; readonly _state: PageState; @@ -161,6 +163,7 @@ export class Page extends EventEmitter { this.accessibility = new accessibility.Accessibility(delegate.getAccessibilityTree.bind(delegate)); this.keyboard = new input.Keyboard(delegate.rawKeyboard, this); this.mouse = new input.Mouse(delegate.rawMouse, this); + this.touchscreen = new input.Touchscreen(delegate.rawTouchscreen, this); this._timeoutSettings = new TimeoutSettings(browserContext._timeoutSettings); this._screenshotter = new Screenshotter(this); this._frameManager = new frames.FrameManager(this); diff --git a/src/server/webkit/wkInput.ts b/src/server/webkit/wkInput.ts index d7d40788f6..4b4da8e956 100644 --- a/src/server/webkit/wkInput.ts +++ b/src/server/webkit/wkInput.ts @@ -127,3 +127,19 @@ export class RawMouseImpl implements input.RawMouse { }); } } + +export class RawTouchscreenImpl implements input.RawTouchscreen { + private readonly _pageProxySession: WKSession; + + constructor(session: WKSession) { + this._pageProxySession = session; + } + + async tap(x: number, y: number, modifiers: Set) { + await this._pageProxySession.send('Input.dispatchTapEvent', { + x, + y, + modifiers: toModifiersMask(modifiers), + }); + } +} diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index a687384c92..3a0d8edde3 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -33,7 +33,7 @@ import { getAccessibilityTree } from './wkAccessibility'; import { WKBrowserContext } from './wkBrowser'; import { WKSession } from './wkConnection'; import { WKExecutionContext } from './wkExecutionContext'; -import { RawKeyboardImpl, RawMouseImpl } from './wkInput'; +import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './wkInput'; import { WKInterceptableRequest } from './wkInterceptableRequest'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKWorkers } from './wkWorkers'; @@ -44,6 +44,7 @@ const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; export class WKPage implements PageDelegate { readonly rawMouse: RawMouseImpl; readonly rawKeyboard: RawKeyboardImpl; + readonly rawTouchscreen: RawTouchscreenImpl; _session: WKSession; private _provisionalPage: WKProvisionalPage | null = null; readonly _page: Page; @@ -74,6 +75,7 @@ export class WKPage implements PageDelegate { this._opener = opener; this.rawKeyboard = new RawKeyboardImpl(pageProxySession); this.rawMouse = new RawMouseImpl(pageProxySession); + this.rawTouchscreen = new RawTouchscreenImpl(pageProxySession); this._contextIdToContext = new Map(); this._page = new Page(this, browserContext); this._workers = new WKWorkers(this._page); diff --git a/test/tap.spec.ts b/test/tap.spec.ts new file mode 100644 index 0000000000..5c1a22b0be --- /dev/null +++ b/test/tap.spec.ts @@ -0,0 +1,198 @@ +/** + * 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 { expect, folio } from './fixtures'; +import { ElementHandle } from '..'; +import type { ServerResponse } from 'http'; + +const fixtures = folio.extend(); +fixtures.page.override(async ({browser}, runTest) => { + const page = await browser.newPage({ + hasTouch: true + }); + await runTest(page); + await page.close(); +}); +const { it } = fixtures.build(); + +it('should send all of the correct events', async ({page}) => { + await page.setContent(` +
a
+
b
+`); + await page.tap('#a'); + const eventsHandle = await trackEvents(await page.$('#b')); + await page.tap('#b'); + // webkit doesnt send pointerenter or pointerleave or mouseout + expect(await eventsHandle.jsonValue()).toEqual([ + 'pointerover', 'pointerenter', + 'pointerdown', 'touchstart', + 'pointerup', 'pointerout', + 'pointerleave', 'touchend', + 'mouseover', 'mouseenter', + 'mousemove', 'mousedown', + 'mouseup', 'click', + ]); +}); + +it('should not send mouse events touchstart is canceled', async ({page}) => { + await page.setContent('hello world'); + await page.evaluate(() => { + // touchstart is not cancelable unless passive is false + document.addEventListener('touchstart', t => t.preventDefault(), {passive: false}); + }); + const eventsHandle = await trackEvents(await page.$('body')); + await page.tap('body'); + expect(await eventsHandle.jsonValue()).toEqual([ + 'pointerover', 'pointerenter', + 'pointerdown', 'touchstart', + 'pointerup', 'pointerout', + 'pointerleave', 'touchend', + ]); +}); + +it('should not send mouse events when touchend is canceled', async ({page}) => { + await page.setContent('hello world'); + await page.evaluate(() => { + document.addEventListener('touchend', t => t.preventDefault()); + }); + const eventsHandle = await trackEvents(await page.$('body')); + await page.tap('body'); + expect(await eventsHandle.jsonValue()).toEqual([ + 'pointerover', 'pointerenter', + 'pointerdown', 'touchstart', + 'pointerup', 'pointerout', + 'pointerleave', 'touchend', + ]); +}); + +it('should wait for a navigation caused by a tap', async ({server, page}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + link; +`); + const responsePromise = new Promise(resolve => { + server.setRoute('/intercept-this.html', (handler, response) => { + resolve(response); + }); + }); + let resolved = false; + const tapPromise = page.tap('a').then(() => resolved = true); + const response = await responsePromise; + // make sure the tap doesnt resolve too early + await new Promise(x => setTimeout(x, 100)); + expect(resolved).toBe(false); + + response.end('foo'); + + await tapPromise; + expect(resolved).toBe(true); +}); + +it('should work with modifiers', async ({page}) => { + await page.setContent('hello world'); + const altKeyPromise = page.evaluate(() => new Promise(resolve => { + document.addEventListener('touchstart', event => { + resolve(event.altKey); + }, {passive: false}); + })); + // make sure the evals hit the page + await page.evaluate(() => void 0); + await page.tap('body', { + modifiers: ['Alt'] + }); + expect(await altKeyPromise).toBe(true); +}); + +it('should send well formed touch points', async ({page}) => { + const promises = Promise.all([ + page.evaluate(() => new Promise(resolve => { + document.addEventListener('touchstart', event => { + resolve([...event.touches].map(t => ({ + identifier: t.identifier, + clientX: t.clientX, + clientY: t.clientY, + pageX: t.pageX, + pageY: t.pageY, + radiusX: 'radiusX' in t ? t.radiusX : t['webkitRadiusX'], + radiusY: 'radiusY' in t ? t.radiusY : t['webkitRadiusY'], + rotationAngle: 'rotationAngle' in t ? t.rotationAngle : t['webkitRotationAngle'], + force: 'force' in t ? t.force : t['webkitForce'], + }))); + }, false); + })), + page.evaluate(() => new Promise(resolve => { + document.addEventListener('touchend', event => { + resolve([...event.touches].map(t => ({ + identifier: t.identifier, + clientX: t.clientX, + clientY: t.clientY, + pageX: t.pageX, + pageY: t.pageY, + radiusX: 'radiusX' in t ? t.radiusX : t['webkitRadiusX'], + radiusY: 'radiusY' in t ? t.radiusY : t['webkitRadiusY'], + rotationAngle: 'rotationAngle' in t ? t.rotationAngle : t['webkitRotationAngle'], + force: 'force' in t ? t.force : t['webkitForce'], + }))); + }, false); + })), + ]); + // make sure the evals hit the page + await page.evaluate(() => void 0); + await page.touchscreen.tap(40, 60); + const [touchstart, touchend] = await promises; + + expect(touchstart).toEqual([{ + clientX: 40, + clientY: 60, + force: 1, + identifier: 0, + pageX: 40, + pageY: 60, + radiusX: 1, + radiusY: 1, + rotationAngle: 0, + }]); + expect(touchend).toEqual([]); +}); + +it('should wait until an element is visible to tap it', async ({page}) => { + const div = await page.evaluateHandle(() => { + const button = document.createElement('button'); + button.textContent = 'not clicked'; + document.body.appendChild(button); + button.style.display = 'none'; + return button; + }); + const tapPromise = div.tap(); + await div.evaluate(div => div.onclick = () => div.textContent = 'clicked'); + await div.evaluate(div => div.style.display = 'block'); + await tapPromise; + expect(await div.textContent()).toBe('clicked'); +}); + +async function trackEvents(target: ElementHandle) { + const eventsHandle = await target.evaluateHandle(target => { + const events: string[] = []; + for (const event of [ + 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'click', + 'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointermove', 'pointerout', 'pointerover', 'pointerup', + 'touchstart', 'touchend', 'touchmove', 'touchcancel', + ]) + target.addEventListener(event, () => events.push(event), false); + return events; + }); + return eventsHandle; +}