diff --git a/docs/src/api/class-touchscreen.md b/docs/src/api/class-touchscreen.md index 90ea8cd759..79fd134dca 100644 --- a/docs/src/api/class-touchscreen.md +++ b/docs/src/api/class-touchscreen.md @@ -20,3 +20,23 @@ Dispatches a `touchstart` and `touchend` event with a single touch at the positi ### param: Touchscreen.tap.y * since: v1.8 - `y` <[float]> + +## async method: Touchscreen.touch +* since: v1.46 + +Synthesizes a touch event. + +### param: Touchscreen.touch.type +* since: v1.46 +- `type` <[TouchType]<"touchstart"|"touchend"|"touchmove"|"touchcancel">> + +Type of the touch event. + +### param: Touchscreen.touch.touches +* since: v1.46 +- `touchPoints` <[Array]<[Object]>> + - `x` <[float]> x coordinate of the event in CSS pixels. + - `y` <[float]> y coordinate of the event in CSS pixels. + - `id` ?<[int]> Identifier used to track the touch point between events, must be unique within an event. Optional. + +List of touch points for this event. `id` is a unique identifier of a touch point that helps identify it between touch events for the duration of its movement around the surface. \ No newline at end of file diff --git a/packages/playwright-core/src/client/input.ts b/packages/playwright-core/src/client/input.ts index e06b0e3e4a..0cc841619f 100644 --- a/packages/playwright-core/src/client/input.ts +++ b/packages/playwright-core/src/client/input.ts @@ -89,4 +89,8 @@ export class Touchscreen implements api.Touchscreen { async tap(x: number, y: number) { await this._page._channel.touchscreenTap({ x, y }); } + + async touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[]) { + await this._page._channel.touchscreenTouch({ type, touchPoints }); + } } diff --git a/packages/playwright-core/src/protocol/debug.ts b/packages/playwright-core/src/protocol/debug.ts index 4f44f5941e..f8e1f6c4b2 100644 --- a/packages/playwright-core/src/protocol/debug.ts +++ b/packages/playwright-core/src/protocol/debug.ts @@ -31,6 +31,7 @@ export const slowMoActions = new Set([ 'Page.mouseClick', 'Page.mouseWheel', 'Page.touchscreenTap', + 'Page.touchscreenTouch', 'Frame.blur', 'Frame.check', 'Frame.click', @@ -89,6 +90,7 @@ export const commandsWithTracingSnapshots = new Set([ 'Page.mouseClick', 'Page.mouseWheel', 'Page.touchscreenTap', + 'Page.touchscreenTouch', 'Frame.evalOnSelector', 'Frame.evalOnSelectorAll', 'Frame.addScriptTag', diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 9187dc13d2..63679d1993 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1237,6 +1237,15 @@ scheme.PageTouchscreenTapParams = tObject({ y: tNumber, }); scheme.PageTouchscreenTapResult = tOptional(tObject({})); +scheme.PageTouchscreenTouchParams = tObject({ + type: tEnum(['touchstart', 'touchend', 'touchmove', 'touchcancel']), + touchPoints: tArray(tObject({ + x: tNumber, + y: tNumber, + id: tOptional(tNumber), + })), +}); +scheme.PageTouchscreenTouchResult = tOptional(tObject({})); scheme.PageAccessibilitySnapshotParams = tObject({ interestingOnly: tOptional(tBoolean), root: tOptional(tChannel(['ElementHandle'])), diff --git a/packages/playwright-core/src/server/chromium/crInput.ts b/packages/playwright-core/src/server/chromium/crInput.ts index bbfd973d10..47e7cc53e9 100644 --- a/packages/playwright-core/src/server/chromium/crInput.ts +++ b/packages/playwright-core/src/server/chromium/crInput.ts @@ -179,4 +179,19 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { }), ]); } + async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set) { + let type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel'; + switch (eventType) { + case 'touchstart': type = 'touchStart'; break; + case 'touchmove': type = 'touchMove'; break; + case 'touchend': type = 'touchEnd'; break; + case 'touchcancel': type = 'touchCancel'; break; + default: throw new Error('Invalid eventType: ' + eventType); + } + await this._client.send('Input.dispatchTouchEvent', { + type, + touchPoints, + modifiers: toModifiersMask(modifiers) + }); + } } diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 3101cd051d..9acc7378b2 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -265,6 +265,10 @@ export class PageDispatcher extends Dispatcher { const rootAXNode = await this._page.accessibility.snapshot({ interestingOnly: params.interestingOnly, diff --git a/packages/playwright-core/src/server/firefox/ffInput.ts b/packages/playwright-core/src/server/firefox/ffInput.ts index 66f35399a5..fcf53ee9fc 100644 --- a/packages/playwright-core/src/server/firefox/ffInput.ts +++ b/packages/playwright-core/src/server/firefox/ffInput.ts @@ -166,4 +166,8 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { modifiers: toModifiersMask(modifiers), }); } + + async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set) { + throw new Error('Not implemented yet.'); + } } diff --git a/packages/playwright-core/src/server/input.ts b/packages/playwright-core/src/server/input.ts index f09f91b86f..501874322c 100644 --- a/packages/playwright-core/src/server/input.ts +++ b/packages/playwright-core/src/server/input.ts @@ -308,6 +308,7 @@ function buildLayoutClosure(layout: keyboardLayout.KeyboardLayout): Map): Promise; + touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set): Promise; } export class Touchscreen { @@ -326,4 +327,19 @@ export class Touchscreen { 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()); } + async touch(type: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], metadata?: CallMetadata) { + if (metadata && touchPoints.length === 1) + metadata.point = { x: touchPoints[0].x, y: touchPoints[0].y }; + if (!this._page._browserContext._options.hasTouch) + throw new Error('hasTouch must be enabled on the browser context before using the touchscreen.'); + const ids = new Set(); + for (const point of touchPoints) { + if (point.id !== undefined) { + if (ids.has(point.id)) + throw new Error(`Duplicate touch point id: ${point.id}`); + ids.add(point.id); + } + } + await this._raw.touch(type, touchPoints, this._page.keyboard._modifiers()); + } } diff --git a/packages/playwright-core/src/server/webkit/wkInput.ts b/packages/playwright-core/src/server/webkit/wkInput.ts index 0732f246d1..88afa774c8 100644 --- a/packages/playwright-core/src/server/webkit/wkInput.ts +++ b/packages/playwright-core/src/server/webkit/wkInput.ts @@ -182,4 +182,20 @@ export class RawTouchscreenImpl implements input.RawTouchscreen { modifiers: toModifiersMask(modifiers), }); } + + async touch(eventType: 'touchstart'|'touchmove'|'touchend'|'touchcancel', touchPoints: { x: number, y: number, id?: number }[], modifiers: Set) { + let type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel'; + switch (eventType) { + case 'touchstart': type = 'touchStart'; break; + case 'touchmove': type = 'touchMove'; break; + case 'touchend': type = 'touchEnd'; break; + case 'touchcancel': type = 'touchCancel'; break; + default: throw new Error('Invalid eventType: ' + eventType); + } + await this._pageProxySession.send('Input.dispatchTouchEvent', { + type, + touchPoints: touchPoints.map(p => ({ ...p, id: p.id || 0 })), + modifiers: toModifiersMask(modifiers) + }); + } } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index f92eeb71a1..3152c067a3 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -19682,6 +19682,29 @@ export interface Touchscreen { * @param y */ tap(x: number, y: number): Promise; + + /** + * Synthesizes a touch event. + * @param type Type of the touch event. + * @param touchPoints List of touch points for this event. `id` is a unique identifier of a touch point that helps identify it between + * touch events for the duration of its movement around the surface. + */ + touch(type: "touchstart"|"touchend"|"touchmove"|"touchcancel", touchPoints: ReadonlyArray<{ + /** + * x coordinate of the event in CSS pixels. + */ + x: number; + + /** + * y coordinate of the event in CSS pixels. + */ + y: number; + + /** + * Identifier used to track the touch point between events, must be unique within an event. Optional. + */ + id?: number; + }>): Promise; } /** diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index ffb591556c..a1ae5a13e0 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -1890,6 +1890,7 @@ export interface PageChannel extends PageEventTarget, EventTargetChannel { mouseClick(params: PageMouseClickParams, metadata?: CallMetadata): Promise; mouseWheel(params: PageMouseWheelParams, metadata?: CallMetadata): Promise; touchscreenTap(params: PageTouchscreenTapParams, metadata?: CallMetadata): Promise; + touchscreenTouch(params: PageTouchscreenTouchParams, metadata?: CallMetadata): Promise; accessibilitySnapshot(params: PageAccessibilitySnapshotParams, metadata?: CallMetadata): Promise; pdf(params: PagePdfParams, metadata?: CallMetadata): Promise; startJSCoverage(params: PageStartJSCoverageParams, metadata?: CallMetadata): Promise; @@ -2257,6 +2258,18 @@ export type PageTouchscreenTapOptions = { }; export type PageTouchscreenTapResult = void; +export type PageTouchscreenTouchParams = { + type: 'touchstart' | 'touchend' | 'touchmove' | 'touchcancel', + touchPoints: { + x: number, + y: number, + id?: number, + }[], +}; +export type PageTouchscreenTouchOptions = { + +}; +export type PageTouchscreenTouchResult = void; export type PageAccessibilitySnapshotParams = { interestingOnly?: boolean, root?: ElementHandleChannel, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 14799ef17e..54f356102b 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -1603,6 +1603,27 @@ Page: slowMo: true snapshot: true + touchscreenTouch: + parameters: + type: + type: enum + literals: + - touchstart + - touchend + - touchmove + - touchcancel + touchPoints: + type: array + items: + type: object + properties: + x: number + y: number + id: number? + flags: + slowMo: true + snapshot: true + accessibilitySnapshot: parameters: interestingOnly: boolean? diff --git a/tests/library/touch.spec.ts b/tests/library/touch.spec.ts new file mode 100644 index 0000000000..f842beb207 --- /dev/null +++ b/tests/library/touch.spec.ts @@ -0,0 +1,76 @@ +/** + * 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 { contextTest as it, expect } from '../config/browserTest'; +import type { Locator } from 'playwright-core'; + +it.use({ hasTouch: true }); + +it.fixme(({ browserName }) => browserName === 'firefox'); + +it('slow swipe events @smoke', async ({ page }) => { + it.fixme(); + await page.setContent(`
a
`); + const eventsHandle = await trackEvents(await page.locator('#a')); + const center = await centerPoint(page.locator('#a')); + await page.touchscreen.touch('touchstart', [{ ...center, id: 1 }]); + expect.soft(await eventsHandle.jsonValue()).toEqual([ + 'pointerover', + 'pointerenter', + 'pointerdown', + 'touchstart', + ]); + + await eventsHandle.evaluate(events => events.length = 0); + await page.touchscreen.touch('touchmove', [{ x: center.x + 10, y: center.y + 10, id: 1 }]); + await page.touchscreen.touch('touchmove', [{ x: center.x + 20, y: center.y + 20, id: 1 }]); + expect.soft(await eventsHandle.jsonValue()).toEqual([ + 'pointermove', + 'touchmove', + 'pointermove', + 'touchmove', + ]); + + await eventsHandle.evaluate(events => events.length = 0); + await page.touchscreen.touch('touchend', [{ x: center.x + 20, y: center.y + 20, id: 1 }]); + expect.soft(await eventsHandle.jsonValue()).toEqual([ + 'pointerup', + 'pointerout', + 'pointerleave', + 'touchend', + ]); +}); + + +async function trackEvents(target: Locator) { + 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), { passive: false }); + return events; + }); + return eventsHandle; +} + +async function centerPoint(e: Locator) { + const box = await e.boundingBox(); + if (!box) + throw new Error('Element is not visible'); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} \ No newline at end of file