Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/e2e/iOS/__tests__/caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
})
})
60 changes: 60 additions & 0 deletions src/e2e/iOS/helpers/doubleTap.ts
Original file line number Diff line number Diff line change
@@ -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
Loading