Skip to content
Draft
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
104 changes: 104 additions & 0 deletions src/e2e/iOS/__tests__/caretFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* IOS Safari caret focus regression test for #4394.
*
* Isolated into its own spec file so it can be pinned to an iOS version whose Safari touch-adjustment
* heuristic reproduces the bug (see wdio.browserstack.conf.ts). The rest of the iOS suite runs on the
* default device/version.
*/
import gesture from '../helpers/gesture'
import getEditingText from '../helpers/getEditingText'
import getElementRectByScreen from '../helpers/getElementRectByScreen'
import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone'
import isKeyboardShown from '../helpers/isKeyboardShown'
import newThought from '../helpers/newThought'
import waitForEditable from '../helpers/waitForEditable'
import waitUntil from '../helpers/waitUntil'

describe('Caret', () => {
it('Focus is prevented after clearing the cursor', async () => {
await newThought('Hello')

const editable = await waitForEditable('Hello')
await hideKeyboardByTappingDone()
await browser.pause(800)
await browser.execute(() => window.scrollTo(0, 0))
await browser.pause(400)

const rect = await getElementRectByScreen(editable)

// Prime with a real native tap on the thought's center + keyboard dismissal. This is required for
// the synthetic edge-touch below to reliably win the timing race that a real finger triggers on
// its own (see comment on the tap). A native tap (not a webview element.click()) is used because
// on real devices only a native touch focuses the editable and opens the keyboard.
const webviewContext = (await browser.getContext()) as string
await browser.switchContext('NATIVE_APP')
await browser.performActions([
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{
type: 'pointerMove',
duration: 0,
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2),
origin: 'viewport',
},
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 60 },
{ type: 'pointerUp', button: 0 },
],
},
])
await browser.switchContext(webviewContext)
await browser.pause(600)
await hideKeyboardByTappingDone()
await browser.pause(400)

// Cursor Back (swipe right) to set the cursor to null, so that "Hello" becomes a non-cursor thought.
await gesture('r', {
xStart: rect.x + 5,
yStart: rect.y + rect.height / 2,
segmentLength: rect.width,
})
await waitUntil(async () => !(await getEditingText()))

// Tap just past the right edge of the thought text, vertically centered, using a finger-sized
// contact area (width/height/pressure). On iOS Safari the touch-adjustment heuristic uses the
// contact radius to retarget the synthesized mouse events onto the nearby editable, but the
// touchstart/touchend land on the thought-annotation overlay - so the editable's onTouchEnd
// never runs to preventDefault. Focus then proceeds on the redirected mousedown and the virtual
// keyboard incorrectly opens. Regression test for #4394.
const tapX = Math.round(rect.x + rect.width + 4)
const tapY = Math.round(rect.y + rect.height / 2)
await browser.switchContext('NATIVE_APP')
await browser.performActions([
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{
type: 'pointerMove',
duration: 0,
x: tapX,
y: tapY,
origin: 'viewport',
width: 40,
height: 40,
pressure: 0.9,
},
{ type: 'pointerDown', button: 0, width: 40, height: 40, pressure: 0.9 },
{ type: 'pause', duration: 90 },
{ type: 'pointerUp', button: 0 },
],
},
])
await browser.switchContext(webviewContext)
await browser.pause(600)

// A non-cursor thought must not open the virtual keyboard.
expect(await isKeyboardShown()).toBe(false)
})
})
209 changes: 209 additions & 0 deletions src/e2e/iOS/__tests__/caretFocusIsolated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* Add iOS Safari isolated-primitive diagnostics for #4394.
*
* These specs strip away all em code and reproduce the bug against DOM primitives: a bare
* contentEditable plus an adjacent non-focusable overlay (mimicking the thought-annotation), driven by
* the exact finger-sized right-edge tap from caretFocus.ts. Pinned to iOS 18 (see
* wdio.browserstack.conf.ts) because the touch-adjustment heuristic that redirects the synthesized
* mouse events onto the editable only reproduces there.
*
* Two hypotheses are probed:
*
* - H2 (first spec): `preventDefault()` on the touch-adjustment-redirected `mousedown` does NOT block
* focus, because focus is a default action of the *touch* sequence, not the mouse event. Passing this
* spec (focus + keyboard despite a successfully-prevented mousedown, with zero em code) confirms H2 is
* a pure iOS Safari platform property.
*
* - Touch-layer fix viability (second spec): since the touchstart/touchend land on the overlay and never
* reach the editable, the only touch we can intercept is the overlay's. This spec preventDefaults on
* the overlay's touch events and asks whether that suppresses the retargeted focus. If it does, a
* touch-layer fix on the annotation/overlay is viable. If focus still proceeds, iOS's focus retarget is
* decoupled from the overlay's touch default and no `preventDefault` can stop it — the fix must prevent
* focus another way (e.g. blur-on-focus, or making the editable unfocusable while it is a non-cursor
* thought).
*/
import getElementRectByScreen from '../helpers/getElementRectByScreen'
import isKeyboardShown from '../helpers/isKeyboardShown'

/** Probe flags recorded on the window by the injected fixture. */
type FixWindow = {
__fixFocused?: boolean
__fixMousedownFired?: boolean
__fixMousedownPrevented?: boolean | null
__fixOverlayTouchFired?: boolean
__fixTouchPrevented?: boolean | null
}

describe('Caret (isolated)', () => {
/**
* Replaces the em app with a minimal fixture: a bare contentEditable and an adjacent non-focusable
* overlay positioned just past its right edge (the role the thought-annotation plays in the app). The
* editable's mousedown handler unconditionally preventDefaults - exactly the condition of useEditMode's
* `else` branch - and records whether it fired and whether the default was prevented. A focus probe
* records whether the editable received focus. When guardOverlayTouch is set, the overlay also
* preventDefaults its touchstart/touchend (the only touch that actually lands during the edge tap).
*/
const injectFixture = (guardOverlayTouch: boolean) =>
browser.execute(guard => {
const w = window as unknown as FixWindow
document.body.innerHTML = ''
w.__fixFocused = false
w.__fixMousedownFired = false
w.__fixMousedownPrevented = null
w.__fixOverlayTouchFired = false
w.__fixTouchPrevented = null

const wrap = document.createElement('div')
wrap.style.cssText = 'position:absolute; top:250px; left:40px;'

const editable = document.createElement('div')
editable.id = 'fixEditable'
editable.setAttribute('contenteditable', 'true')
editable.textContent = 'Hello'
editable.style.cssText = 'display:inline-block; font-size:24px; line-height:1.5; outline:none;'

const overlay = document.createElement('span')
overlay.id = 'fixOverlay'
overlay.style.cssText = 'position:absolute; top:0; left:100%; width:44px; height:100%; z-index:5;'

editable.addEventListener('mousedown', e => {
e.preventDefault()
w.__fixMousedownFired = true
w.__fixMousedownPrevented = e.defaultPrevented
})
editable.addEventListener('focus', () => {
w.__fixFocused = true
})

if (guard) {
/** Setting passive:false is required for preventDefault on touchstart to be honored by Safari. */
const onTouch = (e: Event) => {
e.preventDefault()
w.__fixOverlayTouchFired = true
w.__fixTouchPrevented = e.defaultPrevented
}
overlay.addEventListener('touchstart', onTouch, { passive: false })
overlay.addEventListener('touchend', onTouch, { passive: false })
}

wrap.appendChild(editable)
wrap.appendChild(overlay)
document.body.appendChild(wrap)
}, guardOverlayTouch)

/** Performs a native touch tap at the given screen coordinates, optionally with a finger-sized contact area. */
const nativeTap = async (webviewContext: string, x: number, y: number, finger = false) => {
const contact = finger ? { width: 40, height: 40, pressure: 0.9 } : {}
await browser.switchContext('NATIVE_APP')
try {
await browser.performActions([
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x, y, origin: 'viewport', ...contact },
{ type: 'pointerDown', button: 0, ...contact },
{ type: 'pause', duration: finger ? 90 : 60 },
{ type: 'pointerUp', button: 0 },
],
},
])
} finally {
// Always return to the webview context so a failure here can't strand the session in NATIVE_APP
// (where execute/sync is unavailable and would break subsequent tests).
await browser.switchContext(webviewContext)
}
}

/**
* Prime with a real native tap on the editable's center + keyboard dismissal, then reset the probes.
* This mirrors caretFocus.ts: it warms the timing race so the synthetic edge-touch reliably triggers
* the iOS Safari touch-adjustment retargeting that a real finger would trigger on its own.
*/
const primeAndDismiss = async (
webviewContext: string,
rect: { x: number; y: number; width: number; height: number },
) => {
await nativeTap(webviewContext, Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2))
await browser.pause(600)
// Dismiss the keyboard by blurring the focused editable rather than tapping the native "Done"
// accessory. A bare contentEditable does not reliably produce em's keyboard toolbar, and blur runs
// entirely in the webview context (no native switch, no dependency on a Done button).
await browser.execute(() => (document.activeElement as HTMLElement | null)?.blur())
await browser.pause(400)
await browser.execute(() => {
const w = window as unknown as FixWindow
w.__fixFocused = false
w.__fixMousedownFired = false
w.__fixOverlayTouchFired = false
})
}

/** Reads all probe flags in a single round-trip. */
const readProbes = () =>
browser.execute(() => {
const w = window as unknown as FixWindow
return {
focused: w.__fixFocused === true,
mousedownFired: w.__fixMousedownFired === true,
mousedownPrevented: w.__fixMousedownPrevented === true,
overlayTouchFired: w.__fixOverlayTouchFired === true,
touchPrevented: w.__fixTouchPrevented === true,
}
})

it('H2: mousedown preventDefault does not block focus on a touch-redirected edge tap', async () => {
await injectFixture(false)

const editableEl = await browser.$('#fixEditable').getElement()
const rect = await getElementRectByScreen(editableEl)
const webviewContext = (await browser.getContext()) as string

await primeAndDismiss(webviewContext, rect)

// Tap just past the right edge with a finger-sized contact area. Safari's touch adjustment retargets
// the synthesized mouse events onto the editable while the touchstart/touchend land on the overlay -
// so the editable's mousedown fires and preventDefaults, but focus proceeds anyway. Isolated #4394.
await nativeTap(webviewContext, Math.round(rect.x + rect.width + 4), Math.round(rect.y + rect.height / 2), true)
await browser.pause(600)

const probes = await readProbes()
const keyboard = await isKeyboardShown()

// The mousedown fired and its default was successfully prevented...
expect(probes.mousedownFired).toBe(true)
expect(probes.mousedownPrevented).toBe(true)
// ...yet the editable focused and the keyboard opened anyway, with zero em code involved.
// If both are true, H2 is confirmed: mousedown preventDefault cannot block iOS Safari touch focus.
expect(probes.focused).toBe(true)
expect(keyboard).toBe(true)
})

it('control: preventDefault on the overlay touch (the element the touch actually hits) suppresses focus', async () => {
await injectFixture(true)

const editableEl = await browser.$('#fixEditable').getElement()
const rect = await getElementRectByScreen(editableEl)
const webviewContext = (await browser.getContext()) as string

await primeAndDismiss(webviewContext, rect)

// Same edge tap, but now the overlay preventDefaults its own touch events.
await nativeTap(webviewContext, Math.round(rect.x + rect.width + 4), Math.round(rect.y + rect.height / 2), true)
await browser.pause(600)

const probes = await readProbes()
const keyboard = await isKeyboardShown()

// The touch landed on the overlay and its default was cancelable and prevented.
expect(probes.overlayTouchFired).toBe(true)
expect(probes.touchPrevented).toBe(true)
// Hypothesis under test: preventing the overlay's touch default suppresses the retargeted focus and
// the synthesized mouse cascade. If these pass, a touch-layer fix on the annotation/overlay is
// viable. If focus/keyboard still occur, the fix must prevent focus another way.
expect(probes.mousedownFired).toBe(false)
expect(probes.focused).toBe(false)
expect(keyboard).toBe(false)
})
})
67 changes: 47 additions & 20 deletions src/e2e/iOS/config/wdio.browserstack.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,51 @@ if (!process.env.BROWSERSTACK_ACCESS_KEY) {
const user = process.env.BROWSERSTACK_USERNAME
const date = new Date().toISOString().slice(0, 10)

// The #4394 caret focus regression only reproduces on iOS 18's Safari touch-adjustment heuristic, so
// that single spec runs on an iOS 18 device while the rest of the suite stays on the default iOS 17.
const caretFocusSpec = path.resolve(process.cwd(), 'src/e2e/iOS/__tests__/caretFocus.ts')

// Isolated-primitive diagnostic for the same #4394 heuristic (hypothesis H2); also requires iOS 18.
const caretFocusIsolatedSpec = path.resolve(process.cwd(), 'src/e2e/iOS/__tests__/caretFocusIsolated.ts')

const bstackOptions = {
projectName: process.env.BROWSERSTACK_PROJECT_NAME || 'em',
buildName: process.env.BROWSERSTACK_BUILD_NAME || `Local - ${user} - ${date}`,
local: true,
debug: true,
networkLogs: true,
consoleLogs: 'verbose',
idleTimeout: 60,
}

// Run the whole suite except the #4394 regression on the default device.
const suiteCapability = {
...baseConfig.baseCapabilities,
exclude: [caretFocusSpec, caretFocusIsolatedSpec],
'appium:deviceName': 'iPhone 15 Plus',
'appium:platformVersion': '17',
'bstack:options': {
...bstackOptions,
deviceName: 'iPhone 15 Plus',
osVersion: '17',
sessionName: 'iOS Safari Tests',
},
}

// Run only the #4394 regression, which requires iOS 18 to reproduce.
const caretFocusCapability = {
...baseConfig.baseCapabilities,
specs: [caretFocusSpec, caretFocusIsolatedSpec],
'appium:deviceName': 'iPhone 16 Pro Max',
'appium:platformVersion': '18',
'bstack:options': {
...bstackOptions,
deviceName: 'iPhone 16 Pro Max',
osVersion: '18',
sessionName: 'iOS Safari Tests (iOS 18)',
},
}

/**
* WDIO configuration for BrowserStack iOS testing.
* Uses @wdio/browserstack-service for automatic tunnel management.
Expand All @@ -34,26 +79,8 @@ export const config: WebdriverIO.Config = {
user,
key: process.env.BROWSERSTACK_ACCESS_KEY,

// Capabilities
capabilities: [
{
...baseConfig.baseCapabilities,
'appium:deviceName': 'iPhone 15 Plus',
'appium:platformVersion': '17',
'bstack:options': {
deviceName: 'iPhone 15 Plus',
osVersion: '17',
projectName: process.env.BROWSERSTACK_PROJECT_NAME || 'em',
buildName: process.env.BROWSERSTACK_BUILD_NAME || `Local - ${user} - ${date}`,
sessionName: 'iOS Safari Tests',
local: true,
debug: true,
networkLogs: true,
consoleLogs: 'verbose',
idleTimeout: 60,
},
},
],
// Capabilities (per-capability specs/exclude is a WDIO runtime feature not covered by the strict type)
capabilities: [suiteCapability, caretFocusCapability] as WebdriverIO.Config['capabilities'],

// Services
services: [
Expand Down
Loading
Loading