diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 29e773dc8f0..d7b5fd26bbe 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -4,6 +4,7 @@ */ import gestures from '../../../test-helpers/gestures' import clickThought from '../helpers/clickThought' +import doubleTap from '../helpers/doubleTap' import editThought from '../helpers/editThought' import gesture from '../helpers/gesture' import getEditable from '../helpers/getEditable' @@ -251,4 +252,29 @@ describe('Caret', () => { expect(selectionTextContent).toBe('new') expect(childrenTexts).toEqual(['foo', 'bar']) }) + + it('Set caret on adjacent thought within 1 second (#4173)', async () => { + // Reproduces https://github.com/cybersemics/em/issues/4173: tapping an adjacent thought within + // ~1 second of a previous tap fails to move the cursor. doubleTap fires two real touch taps with + // a deterministic sub-second interval, which a hand-driven repro cannot reliably achieve. The + // interval is overridable via TAP_INTERVAL_MS to probe the threshold. + const intervalMs = process.env.TAP_INTERVAL_MS ? parseInt(process.env.TAP_INTERVAL_MS, 10) : 300 + + const importText = ` + - a + - b + - c` + await paste(importText) + await waitForEditable('c') + + const a = await waitForEditable('a') + const b = await waitForEditable('b') + + // Tap a to set the caret, then within intervalMs tap the adjacent thought b. + await doubleTap(a, b, { intervalMs }) + + // Give the app a moment to dispatch setCursor and re-render, then assert the cursor moved to b. + await browser.pause(1000) + expect(await getEditingText()).toBe('b') + }) }) diff --git a/src/e2e/iOS/helpers/doubleTap.ts b/src/e2e/iOS/helpers/doubleTap.ts new file mode 100644 index 00000000000..ac706b6079e --- /dev/null +++ b/src/e2e/iOS/helpers/doubleTap.ts @@ -0,0 +1,60 @@ +import type { Element } from 'webdriverio' +import getElementRectByScreen from './getElementRectByScreen.js' + +interface Options { + // Milliseconds to pause between the release of the first tap and the press of the second tap. + intervalMs?: number + // Milliseconds each tap is held down before release. + holdMs?: number +} + +/** + * Computes the center point of an element in native screen coordinates. + * Touch pointer actions on iOS Safari (XCUITest) are interpreted in screen coordinates, unlike the + * mouse pointer used by the `tap` helper which uses web viewport coordinates. The screen rect + * adds the native Safari content offset so the touch lands on the element. + */ +const centerOf = async (nodeHandle: Element) => { + const exists = await nodeHandle.isExisting() + if (!exists) throw new Error('Element does not exist in the DOM.') + const rect = await getElementRectByScreen(nodeHandle) + if (!rect) throw new Error('Bounding box of element not found.') + return { + x: Math.round(rect.x + rect.width / 2), + y: Math.round(rect.y + rect.height / 2), + } +} + +/** + * Taps two elements in quick succession with a single touch pointer, with a precisely controlled + * interval between the two taps. Both element coordinates are resolved up front so that the gap + * between the taps is exactly intervalMs rather than incidental round-trip latency (which can exceed + * a second and is the reason a hand-driven repro of #4173 is unreliable). Uses a real touch pointer + * (pointerType: 'touch') so WebKit's rapid-tap gesture recognition is exercised the same way a + * physical double tap would exercise it. See https://github.com/cybersemics/em/issues/4173. + */ +const doubleTap = async (first: Element, second: Element, { intervalMs = 300, holdMs = 60 }: Options = {}) => { + const firstPoint = await centerOf(first) + const secondPoint = await centerOf(second) + + await browser.performActions([ + { + type: 'pointer', + id: 'finger1', + parameters: { pointerType: 'touch' }, + actions: [ + { type: 'pointerMove', duration: 0, x: firstPoint.x, y: firstPoint.y, origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: holdMs }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: intervalMs }, + { type: 'pointerMove', duration: 0, x: secondPoint.x, y: secondPoint.y, origin: 'viewport' }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: holdMs }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]) +} + +export default doubleTap