From 2355aa312cc66485de98936bc880e8340f985022 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 10:04:10 -0700 Subject: [PATCH 01/16] Try to base test on a known reproducible commit --- src/e2e/iOS/__tests__/caret.ts | 19 +++++++++++++++++++ src/e2e/iOS/helpers/tap.ts | 6 ++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 29e773dc8f0..87d41810ef8 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -251,4 +251,23 @@ describe('Caret', () => { expect(selectionTextContent).toBe('new') expect(childrenTexts).toEqual(['foo', 'bar']) }) + + it('Focus is prevented after clearing the cursor', async () => { + await newThought('Hello') + await hideKeyboardByTappingDone() + + // Use a native WebDriver click on the inner anchor to clear the cursor. + // A synthetic tap() (performActions pointer events) does not fire the real + // click event that HomeLink's fastClick onClick handler relies on, so the + // cursor would never be cleared. + await browser.$('[data-testid="home"] a').click() + + await tap(await waitForEditable('Hello'), { horizontalTapLine: 'right', x: 6, y: 60, pointerType: 'touch' }) + + // Wait for focus to be cleared and activeElement to be body + await waitUntil(() => browser.execute(() => document.activeElement === document.body)) + + const activeElementIsBody = await browser.execute(() => document.activeElement === document.body) + expect(activeElementIsBody).toBe(true) + }) }) diff --git a/src/e2e/iOS/helpers/tap.ts b/src/e2e/iOS/helpers/tap.ts index 824f792f39e..dc7033fcbec 100644 --- a/src/e2e/iOS/helpers/tap.ts +++ b/src/e2e/iOS/helpers/tap.ts @@ -5,6 +5,8 @@ import type { Element } from 'webdriverio' interface Options { // Where in the horizontal line (inside) of the target node should be tapped horizontalTapLine?: 'left' | 'right' + // Pointer type to use for the tap action. Defaults to 'mouse'. + pointerType?: 'mouse' | 'touch' // Specify specific node on editable to tap. Overrides horizontalClickLine offset?: number // Number of pixels of x offset to add to the tap coordinates @@ -21,7 +23,7 @@ interface Options { */ const tap = async ( nodeHandle: Element, - { horizontalTapLine = 'left', offset, x = 0, y = 0, releaseDelayMs = 100 }: Options = {}, + { horizontalTapLine = 'left', offset, x = 0, y = 0, pointerType = 'mouse', releaseDelayMs = 100 }: Options = {}, ) => { // Ensure element exists and has an elementId const exists = await nodeHandle.isExisting() @@ -98,7 +100,7 @@ const tap = async ( { type: 'pointer', id: 'pointer1', - parameters: { pointerType: 'mouse' }, + parameters: { pointerType }, actions: [ { type: 'pointerMove', From abb21f360bc26b6d134c693baf3a833eadda6986 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 12:58:59 -0700 Subject: [PATCH 02/16] test(ios): reproduce #4394 keyboard-opens-on-right-edge-tap in caret test The 'Focus is prevented after clearing the cursor' test tapped the right edge with a synthetic point-tap, which never triggered iOS Safari's touch-adjustment retargeting, so the bug could not reproduce and the test always passed. Rework the test to the exact #4394 repro on a non-cursor thought: - create "Hello", dismiss the keyboard, Cursor Back (swipe right) to null the cursor so "Hello" becomes a non-cursor thought - tap ~4px past the right edge, vertically centered, with a finger-sized contact area (width/height/pressure) The finger-sized contact triggers Safari's touch-adjustment: the touch lands on the #thought-annotation overlay (so the editable's onTouchEnd never runs to preventDefault), while the synthesized mousedown is retargeted into the editable, so focus proceeds and the virtual keyboard incorrectly opens. This test now fails on this branch, faithfully demonstrating #4394. It will pass once focus is correctly prevented for non-cursor thoughts. #4394 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 67 ++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 87d41810ef8..fd27ce433b3 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -254,20 +254,65 @@ describe('Caret', () => { it('Focus is prevented after clearing the cursor', async () => { await newThought('Hello') - await hideKeyboardByTappingDone() - // Use a native WebDriver click on the inner anchor to clear the cursor. - // A synthetic tap() (performActions pointer events) does not fire the real - // click event that HomeLink's fastClick onClick handler relies on, so the - // cursor would never be cleared. - await browser.$('[data-testid="home"] a').click() + const editable = await waitForEditable('Hello') + await hideKeyboardByTappingDone() - await tap(await waitForEditable('Hello'), { horizontalTapLine: 'right', x: 6, y: 60, pointerType: 'touch' }) + // Prime with a real tap + keyboard dismissal. This is required for the synthetic touch below to + // reliably win the timing race that a real finger triggers on its own (see comment on the tap). + await clickThought('Hello') + await browser.pause(600) + await hideKeyboardByTappingDone() + await browser.pause(800) + await browser.execute(() => window.scrollTo(0, 0)) + await browser.pause(400) - // Wait for focus to be cleared and activeElement to be body - await waitUntil(() => browser.execute(() => document.activeElement === document.body)) + const rect = await getElementRectByScreen(editable) - const activeElementIsBody = await browser.execute(() => document.activeElement === document.body) - expect(activeElementIsBody).toBe(true) + // 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 }, + ], + }, + ]) + const webviewContext = (await browser.getContexts()).find(c => String(c).startsWith('WEBVIEW')) as string + await browser.switchContext(webviewContext) + await browser.pause(600) + + // A non-cursor thought must not open the virtual keyboard. + expect(await isKeyboardShown()).toBe(false) }) }) From b4c2ac8b3191fde76d5cf5e3c58c016a20dfbf31 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 13:35:32 -0700 Subject: [PATCH 03/16] test(ios): remove sim-specific priming from #4394 caret repro The clickThought priming was tuned to the local simulator's timing race and crashed on BrowserStack real hardware (the priming tap did not reopen the keyboard, so the follow-up Done dismissal found no button). Strip the priming so the test follows #4394's literal repro steps, which real hardware should reproduce like a real finger. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index fd27ce433b3..797a2c3008e 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -257,12 +257,6 @@ describe('Caret', () => { const editable = await waitForEditable('Hello') await hideKeyboardByTappingDone() - - // Prime with a real tap + keyboard dismissal. This is required for the synthetic touch below to - // reliably win the timing race that a real finger triggers on its own (see comment on the tap). - await clickThought('Hello') - await browser.pause(600) - await hideKeyboardByTappingDone() await browser.pause(800) await browser.execute(() => window.scrollTo(0, 0)) await browser.pause(400) From c5e7711e87066d802a59693fa50f9381e26c7f48 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 13:49:19 -0700 Subject: [PATCH 04/16] test(ios): run #4394 caret repro on iPhone 16 Pro Max / iOS 18 BrowserStack Safari 17 did not trigger the touch-adjustment retargeting that #4394 depends on, so the synthetic fat-tap passed. Match the local simulator environment (iPhone 16 Pro Max, iOS 18) where the bug provably reproduces, and restore the clickThought priming that the reproduction requires. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 6 ++++++ src/e2e/iOS/config/wdio.browserstack.conf.ts | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 797a2c3008e..fd27ce433b3 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -257,6 +257,12 @@ describe('Caret', () => { const editable = await waitForEditable('Hello') await hideKeyboardByTappingDone() + + // Prime with a real tap + keyboard dismissal. This is required for the synthetic touch below to + // reliably win the timing race that a real finger triggers on its own (see comment on the tap). + await clickThought('Hello') + await browser.pause(600) + await hideKeyboardByTappingDone() await browser.pause(800) await browser.execute(() => window.scrollTo(0, 0)) await browser.pause(400) diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index 5f2455acabc..055e21cf4fb 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -38,11 +38,11 @@ export const config: WebdriverIO.Config = { capabilities: [ { ...baseConfig.baseCapabilities, - 'appium:deviceName': 'iPhone 15 Plus', - 'appium:platformVersion': '17', + 'appium:deviceName': 'iPhone 16 Pro Max', + 'appium:platformVersion': '18', 'bstack:options': { - deviceName: 'iPhone 15 Plus', - osVersion: '17', + deviceName: 'iPhone 16 Pro Max', + osVersion: '18', projectName: process.env.BROWSERSTACK_PROJECT_NAME || 'em', buildName: process.env.BROWSERSTACK_BUILD_NAME || `Local - ${user} - ${date}`, sessionName: 'iOS Safari Tests', From b4a80a9ef1d50721f04b0ad34342641e695e9602 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 14:01:22 -0700 Subject: [PATCH 05/16] test(ios): prime #4394 caret repro with a native tap instead of webview click On BrowserStack real devices a webview element.click() (clickThought) does not focus the editable or open the keyboard, so the priming's follow-up Done dismissal crashed with 'element wasn't found'. Replace the priming with a native performActions tap on the thought's center, which focuses the editable and opens the keyboard on real hardware, then restore the webview context for the subsequent cursorBack + edge-tap steps. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 38 +++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index fd27ce433b3..001e745b30b 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -257,18 +257,43 @@ describe('Caret', () => { const editable = await waitForEditable('Hello') await hideKeyboardByTappingDone() - - // Prime with a real tap + keyboard dismissal. This is required for the synthetic touch below to - // reliably win the timing race that a real finger triggers on its own (see comment on the tap). - await clickThought('Hello') - await browser.pause(600) - 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.releaseActions() + 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, @@ -308,7 +333,6 @@ describe('Caret', () => { ], }, ]) - const webviewContext = (await browser.getContexts()).find(c => String(c).startsWith('WEBVIEW')) as string await browser.switchContext(webviewContext) await browser.pause(600) From d0b2ad856588afc2de5dc9bb38f647734f34c045 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 14:07:39 -0700 Subject: [PATCH 06/16] test(ios): drop unsupported releaseActions after native priming tap Safari/XCUITest does not support the DELETE /actions endpoint that browser.releaseActions() calls, which aborted the test right after the native priming tap. performActions does not require an explicit release here (the gesture helper avoids it for the same reason). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 001e745b30b..31efdb8b5c9 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -288,7 +288,6 @@ describe('Caret', () => { ], }, ]) - await browser.releaseActions() await browser.switchContext(webviewContext) await browser.pause(600) await hideKeyboardByTappingDone() From 71a8e813e06251a96d266923f9913c8c9f79d29e Mon Sep 17 00:00:00 2001 From: Ethan James Date: Mon, 6 Jul 2026 14:19:57 -0700 Subject: [PATCH 07/16] test(ios): isolate #4394 caret focus repro onto an iOS 18 capability The regression only reproduces on iOS 18's Safari touch-adjustment heuristic. Move the test into its own spec file (caretFocus.ts) and give BrowserStack two capabilities: the existing iOS 17 device runs the whole suite except this spec, and a new iOS 18 device (iPhone 16 Pro Max) runs only this spec. This scopes the intended failure and keeps the rest of the suite off iOS 18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 87 ---------------- src/e2e/iOS/__tests__/caretFocus.ts | 104 +++++++++++++++++++ src/e2e/iOS/config/wdio.browserstack.conf.ts | 64 ++++++++---- 3 files changed, 148 insertions(+), 107 deletions(-) create mode 100644 src/e2e/iOS/__tests__/caretFocus.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 31efdb8b5c9..29e773dc8f0 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -251,91 +251,4 @@ describe('Caret', () => { expect(selectionTextContent).toBe('new') expect(childrenTexts).toEqual(['foo', 'bar']) }) - - 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) - }) }) diff --git a/src/e2e/iOS/__tests__/caretFocus.ts b/src/e2e/iOS/__tests__/caretFocus.ts new file mode 100644 index 00000000000..60c8c454cf6 --- /dev/null +++ b/src/e2e/iOS/__tests__/caretFocus.ts @@ -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) + }) +}) diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index 055e21cf4fb..f0bc968f3d8 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -17,6 +17,48 @@ 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') + +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], + '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], + '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. @@ -34,26 +76,8 @@ export const config: WebdriverIO.Config = { user, key: process.env.BROWSERSTACK_ACCESS_KEY, - // Capabilities - capabilities: [ - { - ...baseConfig.baseCapabilities, - 'appium:deviceName': 'iPhone 16 Pro Max', - 'appium:platformVersion': '18', - 'bstack:options': { - deviceName: 'iPhone 16 Pro Max', - osVersion: '18', - 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: [ From cd42500dcad8ab194efdd0011679467c0e7b5b50 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:14:01 -0700 Subject: [PATCH 08/16] Minimal test case for caret focus --- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 148 +++++++++++++++++++ src/e2e/iOS/config/wdio.browserstack.conf.ts | 7 +- 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 src/e2e/iOS/__tests__/caretFocusIsolated.ts diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts new file mode 100644 index 00000000000..6b91da9139c --- /dev/null +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -0,0 +1,148 @@ +/** + * iOS Safari isolated-primitive diagnostic for #4394 (hypothesis H2). + * + * H2: On iOS Safari, `preventDefault()` on the synthesized/touch-adjustment-redirected `mousedown` does + * NOT prevent the editable from receiving focus, because focus is a default action of the *touch* + * sequence rather than the mouse event. useEditMode's `else` branch already calls `e.preventDefault()` + * on mousedown (and `e.defaultPrevented` is observably `true`), yet the keyboard still opens. + * + * This spec strips away all em code: it replaces the document with a bare contentEditable plus an + * adjacent non-focusable overlay (mimicking the thought-annotation), wires `mousedown -> preventDefault` + * on the editable, and replays the exact finger-sized right-edge tap from caretFocus.ts. If the editable + * focuses / the keyboard opens here — with zero em code involved — the behavior is a pure iOS Safari + * platform property, confirming H2. + * + * Pinned to iOS 18 (see wdio.browserstack.conf.ts) because the touch-adjustment heuristic that redirects + * the mouse events onto the editable only reproduces there. + */ +import getElementRectByScreen from '../helpers/getElementRectByScreen' +import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' +import isKeyboardShown from '../helpers/isKeyboardShown' + +describe('Caret (isolated)', () => { + it('H2: mousedown preventDefault does not block focus on a touch-redirected edge tap', async () => { + // Replace 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 the default was actually prevented. A focus + // probe records whether the editable received focus. + await browser.execute(() => { + const w = window as unknown as { __fixFocused?: boolean; __fixMousedownPrevented?: boolean | null } + document.body.innerHTML = '' + w.__fixFocused = false + w.__fixMousedownPrevented = 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;' + + // Non-focusable overlay just past the editable's right edge. Sits above the editable so the + // touchstart/touchend land on it (as the thought-annotation does), while Safari's touch + // adjustment may still redirect the synthesized mousedown onto the editable. + 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.__fixMousedownPrevented = e.defaultPrevented + }) + editable.addEventListener('focus', () => { + w.__fixFocused = true + }) + + wrap.appendChild(editable) + wrap.appendChild(overlay) + document.body.appendChild(wrap) + }) + + const editableEl = await browser.$('#fixEditable').getElement() + const rect = await getElementRectByScreen(editableEl) + + const webviewContext = (await browser.getContext()) as string + + // Prime with a real native tap on the editable's center + keyboard dismissal. This mirrors + // caretFocus.ts: it warms the timing race so the synthetic edge-touch below reliably triggers the + // iOS Safari touch-adjustment retargeting that a real finger would trigger on its own. + 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) + + // Reset the focus probe so it reflects only the decisive edge tap below. + await browser.execute(() => { + ;(window as unknown as { __fixFocused?: boolean }).__fixFocused = false + }) + + // Tap just past the right edge of the editable, vertically centered, with 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, while the + // touchstart/touchend land on the overlay - so the editable's mousedown fires and preventDefaults, + // but focus (a touch default action) proceeds anyway. This is the isolated form of #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) + + const prevented = await browser.execute( + () => (window as unknown as { __fixMousedownPrevented?: boolean | null }).__fixMousedownPrevented === true, + ) + const focused = await browser.execute(() => (window as unknown as { __fixFocused?: boolean }).__fixFocused === true) + const keyboard = await isKeyboardShown() + + // The mousedown default was successfully prevented... + expect(prevented).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(focused).toBe(true) + expect(keyboard).toBe(true) + }) +}) diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index f0bc968f3d8..20facf9e67f 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -21,6 +21,9 @@ const date = new Date().toISOString().slice(0, 10) // 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}`, @@ -34,7 +37,7 @@ const bstackOptions = { // Run the whole suite except the #4394 regression on the default device. const suiteCapability = { ...baseConfig.baseCapabilities, - exclude: [caretFocusSpec], + exclude: [caretFocusSpec, caretFocusIsolatedSpec], 'appium:deviceName': 'iPhone 15 Plus', 'appium:platformVersion': '17', 'bstack:options': { @@ -48,7 +51,7 @@ const suiteCapability = { // Run only the #4394 regression, which requires iOS 18 to reproduce. const caretFocusCapability = { ...baseConfig.baseCapabilities, - specs: [caretFocusSpec], + specs: [caretFocusSpec, caretFocusIsolatedSpec], 'appium:deviceName': 'iPhone 16 Pro Max', 'appium:platformVersion': '18', 'bstack:options': { From 67fd3ee5ac64f6bc6b4154bbca078b228f98777f Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:22:30 -0700 Subject: [PATCH 09/16] test(ios): add isolated-primitive diagnostics for caret focus (H2 + overlay-touch control) --- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 218 ++++++++++++-------- 1 file changed, 136 insertions(+), 82 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts index 6b91da9139c..02cfe53b783 100644 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -1,36 +1,58 @@ /** - * iOS Safari isolated-primitive diagnostic for #4394 (hypothesis H2). + * iOS Safari isolated-primitive diagnostics for #4394. * - * H2: On iOS Safari, `preventDefault()` on the synthesized/touch-adjustment-redirected `mousedown` does - * NOT prevent the editable from receiving focus, because focus is a default action of the *touch* - * sequence rather than the mouse event. useEditMode's `else` branch already calls `e.preventDefault()` - * on mousedown (and `e.defaultPrevented` is observably `true`), yet the keyboard still opens. + * 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. * - * This spec strips away all em code: it replaces the document with a bare contentEditable plus an - * adjacent non-focusable overlay (mimicking the thought-annotation), wires `mousedown -> preventDefault` - * on the editable, and replays the exact finger-sized right-edge tap from caretFocus.ts. If the editable - * focuses / the keyboard opens here — with zero em code involved — the behavior is a pure iOS Safari - * platform property, confirming H2. + * Two hypotheses are probed: * - * Pinned to iOS 18 (see wdio.browserstack.conf.ts) because the touch-adjustment heuristic that redirects - * the mouse events onto the editable only reproduces there. + * - 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 hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' 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)', () => { - it('H2: mousedown preventDefault does not block focus on a touch-redirected edge tap', async () => { - // Replace 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 the default was actually prevented. A focus - // probe records whether the editable received focus. - await browser.execute(() => { - const w = window as unknown as { __fixFocused?: boolean; __fixMousedownPrevented?: boolean | null } + /** + * 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;' @@ -41,34 +63,38 @@ describe('Caret (isolated)', () => { editable.textContent = 'Hello' editable.style.cssText = 'display:inline-block; font-size:24px; line-height:1.5; outline:none;' - // Non-focusable overlay just past the editable's right edge. Sits above the editable so the - // touchstart/touchend land on it (as the thought-annotation does), while Safari's touch - // adjustment may still redirect the synthesized mousedown onto the editable. 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) { + // 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) - const editableEl = await browser.$('#fixEditable').getElement() - const rect = await getElementRectByScreen(editableEl) - - const webviewContext = (await browser.getContext()) as string - - // Prime with a real native tap on the editable's center + keyboard dismissal. This mirrors - // caretFocus.ts: it warms the timing race so the synthetic edge-touch below reliably triggers the - // iOS Safari touch-adjustment retargeting that a real finger would trigger on its own. + /** 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') await browser.performActions([ { @@ -76,73 +102,101 @@ describe('Caret (isolated)', () => { 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: 'pointerMove', duration: 0, x, y, origin: 'viewport', ...contact }, + { type: 'pointerDown', button: 0, ...contact }, + { type: 'pause', duration: finger ? 90 : 60 }, { type: 'pointerUp', button: 0 }, ], }, ]) 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) await hideKeyboardByTappingDone() await browser.pause(400) - - // Reset the focus probe so it reflects only the decisive edge tap below. await browser.execute(() => { - ;(window as unknown as { __fixFocused?: boolean }).__fixFocused = false + const w = window as unknown as FixWindow + w.__fixFocused = false + w.__fixMousedownFired = false + w.__fixOverlayTouchFired = false }) + } - // Tap just past the right edge of the editable, vertically centered, with 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, while the - // touchstart/touchend land on the overlay - so the editable's mousedown fires and preventDefaults, - // but focus (a touch default action) proceeds anyway. This is the isolated form of #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) + /** 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 prevented = await browser.execute( - () => (window as unknown as { __fixMousedownPrevented?: boolean | null }).__fixMousedownPrevented === true, - ) - const focused = await browser.execute(() => (window as unknown as { __fixFocused?: boolean }).__fixFocused === true) + const probes = await readProbes() const keyboard = await isKeyboardShown() - // The mousedown default was successfully prevented... - expect(prevented).toBe(true) + // 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(focused).toBe(true) + 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) + }) }) From 7acbcde90a1223d0b4836d73be27f698db3147eb Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:31:43 -0700 Subject: [PATCH 10/16] Fix lint errors --- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts index 02cfe53b783..efb68668089 100644 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -1,5 +1,5 @@ /** - * iOS Safari isolated-primitive diagnostics for #4394. + * 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 @@ -10,17 +10,17 @@ * 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. + * 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). + * 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 hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' @@ -77,7 +77,7 @@ describe('Caret (isolated)', () => { }) if (guard) { - // passive:false is required for preventDefault on touchstart to be honored by Safari. + /** Setting passive:false is required for preventDefault on touchstart to be honored by Safari. */ const onTouch = (e: Event) => { e.preventDefault() w.__fixOverlayTouchFired = true From 14d83a0e1a6d023e9a11d22dbe29dbae83d8f798 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Tue, 7 Jul 2026 13:50:56 -0700 Subject: [PATCH 11/16] test(ios): dismiss keyboard via blur in isolated caret fixture Replace hideKeyboardByTappingDone (which depends on em's Done accessory, absent for a bare contentEditable) with a webview-context blur, and make nativeTap restore the webview context in a finally so a failed tap can't strand the session in NATIVE_APP and break the next test. --- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 39 ++++++++++++--------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts index efb68668089..bcef8bbb5f2 100644 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -23,7 +23,6 @@ * thought). */ import getElementRectByScreen from '../helpers/getElementRectByScreen' -import hideKeyboardByTappingDone from '../helpers/hideKeyboardByTappingDone' import isKeyboardShown from '../helpers/isKeyboardShown' /** Probe flags recorded on the window by the injected fixture. */ @@ -96,20 +95,25 @@ describe('Caret (isolated)', () => { 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') - 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 }, - ], - }, - ]) - await browser.switchContext(webviewContext) + 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) + } } /** @@ -123,7 +127,10 @@ describe('Caret (isolated)', () => { ) => { await nativeTap(webviewContext, Math.round(rect.x + rect.width / 2), Math.round(rect.y + rect.height / 2)) await browser.pause(600) - await hideKeyboardByTappingDone() + // 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 From d928e2c549c8732dc531489447477870306eae2a Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 11:19:26 -0700 Subject: [PATCH 12/16] test(ios): pin #4394 root cause to stale offsetRef onMouseUp Re-model the isolated caret-focus diagnostics around the confirmed root cause: pre-#4371 useEditMode never resets offsetRef.current, so the retargeted mouseup runs onMouseUp -> setCaretOffset -> selection.set and focuses the editable despite onMouseDown preventDefaulting the native focus. The reproduce spec adds a stale-offset mouseup selection (models pre-#4371) and shows focus/keyboard proceed; the control spec omits it (models post-#4371) and shows focus stays blocked. Make caretFocus.ts version-aware and add a dedicated iOS 17 BrowserStack capability so the same edge tap positively asserts the keyboard opens on iOS 18 but stays down on iOS 17, verifying the bug is iOS-18-specific. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretFocus.ts | 52 +++++-- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 155 +++++++++++-------- src/e2e/iOS/config/wdio.browserstack.conf.ts | 22 ++- 3 files changed, 151 insertions(+), 78 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretFocus.ts b/src/e2e/iOS/__tests__/caretFocus.ts index 60c8c454cf6..22c1f27f627 100644 --- a/src/e2e/iOS/__tests__/caretFocus.ts +++ b/src/e2e/iOS/__tests__/caretFocus.ts @@ -1,9 +1,14 @@ /** - * 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. + * IOS Safari caret focus diagnostic for #4394. Tapping ~4px past the right edge of a non-cursor thought + * incorrectly opens the virtual keyboard. The cause is iOS 18 Safari's touch-adjustment heuristic, which + * retargets the synthesized mouse cascade onto the nearby editable. On the pre-#4371 code the retargeted + * mouseup then runs onMouseUp with a stale, never-reset offsetRef.current, calling setCaretOffset -> + * selection.set, which focuses the editable and opens the keyboard even though onMouseDown preventDefaulted + * the native focus. See caretFocusIsolated.ts for the isolated-primitive proof. This spec verifies that the + * bug is iOS-18-specific by running the identical edge tap on two devices (see wdio.browserstack.conf.ts) + * and asserting the outcome per platform version. On iOS 18 the retargeting fires, so on this pre-#4371 + * branch the keyboard opens (the #4394 reproduction). On iOS 17 the retargeting does not fire, so the + * keyboard stays down (no reproduction). */ import gesture from '../helpers/gesture' import getEditingText from '../helpers/getEditingText' @@ -14,8 +19,27 @@ import newThought from '../helpers/newThought' import waitForEditable from '../helpers/waitForEditable' import waitUntil from '../helpers/waitUntil' +/** Reads the iOS major version from the active session capabilities. */ +const getIOSMajorVersion = (): number => { + const caps = browser.capabilities as Record + const requested = (browser.requestedCapabilities ?? {}) as Record + const bstackOptions = + ((caps['bstack:options'] ?? requested['bstack:options']) as Record | undefined) ?? {} + const rawVersion = (caps['appium:platformVersion'] ?? + caps['platformVersion'] ?? + requested['appium:platformVersion'] ?? + requested['platformVersion'] ?? + bstackOptions['osVersion'] ?? + '') as string | number + return parseInt(String(rawVersion), 10) +} + describe('Caret', () => { - it('Focus is prevented after clearing the cursor', async () => { + it('Keyboard opens on the edge tap only on iOS 18, not iOS 17', async () => { + const iosMajorVersion = getIOSMajorVersion() + // The touch-adjustment retargeting that #4394 depends on only fires on iOS 18's Safari. + const shouldReproduce = iosMajorVersion >= 18 + await newThought('Hello') const editable = await waitForEditable('Hello') @@ -29,7 +53,8 @@ describe('Caret', () => { // 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. + // on real devices only a native touch focuses the editable and opens the keyboard. Priming while + // "Hello" has the cursor is also what leaves offsetRef.current set (and never reset) on pre-#4371. const webviewContext = (await browser.getContext()) as string await browser.switchContext('NATIVE_APP') await browser.performActions([ @@ -65,11 +90,11 @@ describe('Caret', () => { 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 area (width/height/pressure). On iOS 18 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. + // never runs to preventDefault. The retargeted mouseup then focuses the editable via the stale + // offset and the virtual keyboard opens. #4394. const tapX = Math.round(rect.x + rect.width + 4) const tapY = Math.round(rect.y + rect.height / 2) await browser.switchContext('NATIVE_APP') @@ -98,7 +123,10 @@ describe('Caret', () => { await browser.switchContext(webviewContext) await browser.pause(600) - // A non-cursor thought must not open the virtual keyboard. - expect(await isKeyboardShown()).toBe(false) + const keyboard = await isKeyboardShown() + + // Positively assert the version-specific behavior: the edge tap opens the keyboard on iOS 18 (the + // #4394 reproduction, present on this pre-#4371 branch) but leaves it down on iOS 17. + expect(keyboard).toBe(shouldReproduce) }) }) diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts index bcef8bbb5f2..7c984456d16 100644 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -1,26 +1,30 @@ /** - * Add iOS Safari isolated-primitive diagnostics for #4394. + * 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. + * These specs strip away all em code and reproduce the bug against DOM primitives using a bare + * contentEditable plus an adjacent non-focusable overlay that mimics the thought-annotation, driven by the + * exact finger-sized right-edge tap from the caretFocus spec. 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: + * The root cause of #4394 on the pre-#4371 code is not a platform property of `mousedown`. In + * `useEditMode.ts`, `offsetRef.current` is assigned when a thought that has the cursor is tapped, but it is + * never reset back to null. `onMouseUp` then reads that stale offset and calls `setCaretOffset` -> + * `selection.set`, which focuses the editable and opens the keyboard. So the focus that #4394 complains + * about is driven programmatically from `onMouseUp` using a stale offset, not by the native focus default + * of the tap. On the #4394 edge tap the editable's `onMouseDown` runs its else branch and calls + * `e.preventDefault()`, which does block the native focus default. The keyboard still opens only because + * the retargeted `mouseup` fires on the editable and the stale-offset `selection.set` focuses it. This is + * exactly what PR #4371 fixed by deleting `onMouseUp`/`offsetRef` and moving `setCaretOffset` into a + * guarded `onMouseDown`. * - * - 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). + * Two configurations are probed, both under the identical iOS 18 edge tap. The reproduce spec models + * pre-#4371: the editable's `mousedown` preventDefaults (blocking native focus) and a `mouseup` handler + * calls `selection.set` using a stale, never-reset offset. Focus and the keyboard proceed anyway, pinning + * the cause to the stale-offset `onMouseUp` with zero em code. The control spec models post-#4371: the + * editable's `mousedown` preventDefaults and there is no `mouseup` selection at all. Focus is blocked and + * the keyboard stays down, confirming that removing the stale-offset `onMouseUp` (what #4371 did) fixes the + * bug. */ import getElementRectByScreen from '../helpers/getElementRectByScreen' import isKeyboardShown from '../helpers/isKeyboardShown' @@ -30,8 +34,8 @@ type FixWindow = { __fixFocused?: boolean __fixMousedownFired?: boolean __fixMousedownPrevented?: boolean | null - __fixOverlayTouchFired?: boolean - __fixTouchPrevented?: boolean | null + __fixMouseupFired?: boolean + __fixSelectionSet?: boolean } describe('Caret (isolated)', () => { @@ -39,19 +43,23 @@ 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). + * `else` branch - blocking the native focus default and recording whether it fired and was prevented. + * + * When staleOffsetMouseup is set, the editable additionally gets a mouseup handler that models the + * pre-#4371 `onMouseUp`: it reads a stale, never-reset offset and sets the DOM selection inside the + * editable exactly as `device/selection.set` does (removeAllRanges + addRange, no explicit focus()), + * which focuses the contentEditable. When it is not set, there is no mouseup selection, modeling the + * post-#4371 code where `onMouseUp` was removed entirely. */ - const injectFixture = (guardOverlayTouch: boolean) => - browser.execute(guard => { + const injectFixture = (staleOffsetMouseup: boolean) => + browser.execute(withMouseup => { 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 + w.__fixMouseupFired = false + w.__fixSelectionSet = false const wrap = document.createElement('div') wrap.style.cssText = 'position:absolute; top:250px; left:40px;' @@ -66,6 +74,7 @@ describe('Caret (isolated)', () => { overlay.id = 'fixOverlay' overlay.style.cssText = 'position:absolute; top:0; left:100%; width:44px; height:100%; z-index:5;' + // Models useEditMode's `else` branch: preventDefault blocks the native focus default of the tap. editable.addEventListener('mousedown', e => { e.preventDefault() w.__fixMousedownFired = true @@ -75,21 +84,34 @@ describe('Caret (isolated)', () => { 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 }) + if (withMouseup) { + // A stale, never-reset offset, standing in for `offsetRef.current` which useEditMode assigns when a + // cursor thought is tapped and never resets to null. + const staleOffset = 2 + + // Models pre-#4371 `onMouseUp` -> `setCaretOffset` -> `selection.set`: set the DOM selection inside + // the editable using the stale offset. This focuses the contentEditable even though the mousedown + // default was prevented, which is the #4394 focus leak. + editable.addEventListener('mouseup', () => { + w.__fixMouseupFired = true + const textNode = editable.firstChild + if (!textNode) return + const clamped = Math.min(staleOffset, textNode.textContent?.length ?? 0) + const range = document.createRange() + range.setStart(textNode, clamped) + range.collapse(true) + const sel = window.getSelection() + if (!sel) return + sel.removeAllRanges() + sel.addRange(range) + w.__fixSelectionSet = true + }) } wrap.appendChild(editable) wrap.appendChild(overlay) document.body.appendChild(wrap) - }, guardOverlayTouch) + }, staleOffsetMouseup) /** 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) => { @@ -132,11 +154,14 @@ describe('Caret (isolated)', () => { // 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) + // Reset only the transient probe flags. The stale offset intentionally persists, mirroring + // offsetRef.current never being reset in useEditMode. await browser.execute(() => { const w = window as unknown as FixWindow w.__fixFocused = false w.__fixMousedownFired = false - w.__fixOverlayTouchFired = false + w.__fixMouseupFired = false + w.__fixSelectionSet = false }) } @@ -148,61 +173,61 @@ describe('Caret (isolated)', () => { focused: w.__fixFocused === true, mousedownFired: w.__fixMousedownFired === true, mousedownPrevented: w.__fixMousedownPrevented === true, - overlayTouchFired: w.__fixOverlayTouchFired === true, - touchPrevented: w.__fixTouchPrevented === true, + mouseupFired: w.__fixMouseupFired === true, + selectionSet: w.__fixSelectionSet === true, } }) - it('H2: mousedown preventDefault does not block focus on a touch-redirected edge tap', async () => { - await injectFixture(false) + /** Performs the finger-sized tap ~4px past the editable's right edge that triggers the #4394 retargeting. */ + const edgeTap = async (webviewContext: string, rect: { x: number; y: number; width: number; height: number }) => { + await nativeTap(webviewContext, Math.round(rect.x + rect.width + 4), Math.round(rect.y + rect.height / 2), true) + await browser.pause(600) + } + + it('reproduce: stale-offset onMouseUp selection focuses the editable despite a prevented mousedown', 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) - - // 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) + await edgeTap(webviewContext, rect) const probes = await readProbes() const keyboard = await isKeyboardShown() - // The mousedown fired and its default was successfully prevented... + // The mousedown fired on the retargeted editable and its native focus 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. + // The retargeted mouseup also fired on the editable and ran the stale-offset selection.set. + expect(probes.mouseupFired).toBe(true) + expect(probes.selectionSet).toBe(true) + // ...yet the editable focused and the keyboard opened anyway - driven by the programmatic selection, + // not the native tap default. This isolates #4394 to the stale-offset onMouseUp with zero em code. 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) + it('control: with no onMouseUp selection (post-#4371), the prevented mousedown keeps focus down', 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) - - // 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) + await edgeTap(webviewContext, rect) 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) + // The same retargeting occurs: the mousedown fires on the editable and is prevented. + expect(probes.mousedownFired).toBe(true) + expect(probes.mousedownPrevented).toBe(true) + // With the stale-offset onMouseUp removed (what #4371 did), nothing focuses the editable... + expect(probes.selectionSet).toBe(false) + // ...so focus is blocked and the keyboard stays down. expect(probes.focused).toBe(false) expect(keyboard).toBe(false) }) diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index 20facf9e67f..6e9be9f5bb6 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -62,6 +62,22 @@ const caretFocusCapability = { }, } +// Run the #4394 caret focus spec on iOS 17 as well, to positively verify the bug is iOS-18-specific: +// the same edge tap that opens the keyboard on iOS 18 must leave it down on iOS 17. The isolated +// diagnostic depends on the iOS 18 retargeting, so it is not run here. +const caretFocusIOS17Capability = { + ...baseConfig.baseCapabilities, + specs: [caretFocusSpec], + 'appium:deviceName': 'iPhone 15 Plus', + 'appium:platformVersion': '17', + 'bstack:options': { + ...bstackOptions, + deviceName: 'iPhone 15 Plus', + osVersion: '17', + sessionName: 'iOS Safari Tests (caret focus, iOS 17)', + }, +} + /** * WDIO configuration for BrowserStack iOS testing. * Uses @wdio/browserstack-service for automatic tunnel management. @@ -80,7 +96,11 @@ export const config: WebdriverIO.Config = { key: process.env.BROWSERSTACK_ACCESS_KEY, // Capabilities (per-capability specs/exclude is a WDIO runtime feature not covered by the strict type) - capabilities: [suiteCapability, caretFocusCapability] as WebdriverIO.Config['capabilities'], + capabilities: [ + suiteCapability, + caretFocusCapability, + caretFocusIOS17Capability, + ] as WebdriverIO.Config['capabilities'], // Services services: [ From bd3bcf4370ab571ddff515db99552ab000ce3f57 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 11:46:28 -0700 Subject: [PATCH 13/16] test(ios): assert #4394 reproduces on iOS 17 and 18 Real BrowserStack devices show the right-edge tap opens the keyboard on iOS 17 as well as iOS 18, refuting the iOS-18-only premise. The stale offsetRef -> onMouseUp -> selection.set focus path is version-independent, so caretFocus.ts now asserts the keyboard opens on both devices (dropping the version-aware branching) and the config/isolated comments are updated to match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretFocus.ts | 58 +++++++------------- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 8 +-- src/e2e/iOS/config/wdio.browserstack.conf.ts | 15 ++--- 3 files changed, 32 insertions(+), 49 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretFocus.ts b/src/e2e/iOS/__tests__/caretFocus.ts index 22c1f27f627..713687df2fb 100644 --- a/src/e2e/iOS/__tests__/caretFocus.ts +++ b/src/e2e/iOS/__tests__/caretFocus.ts @@ -1,14 +1,14 @@ /** - * IOS Safari caret focus diagnostic for #4394. Tapping ~4px past the right edge of a non-cursor thought - * incorrectly opens the virtual keyboard. The cause is iOS 18 Safari's touch-adjustment heuristic, which - * retargets the synthesized mouse cascade onto the nearby editable. On the pre-#4371 code the retargeted - * mouseup then runs onMouseUp with a stale, never-reset offsetRef.current, calling setCaretOffset -> - * selection.set, which focuses the editable and opens the keyboard even though onMouseDown preventDefaulted - * the native focus. See caretFocusIsolated.ts for the isolated-primitive proof. This spec verifies that the - * bug is iOS-18-specific by running the identical edge tap on two devices (see wdio.browserstack.conf.ts) - * and asserting the outcome per platform version. On iOS 18 the retargeting fires, so on this pre-#4371 - * branch the keyboard opens (the #4394 reproduction). On iOS 17 the retargeting does not fire, so the - * keyboard stays down (no reproduction). + * IOS Safari caret focus reproduction for #4394. Tapping ~4px past the right edge of a non-cursor thought + * incorrectly opens the virtual keyboard. Safari's touch-adjustment heuristic retargets the synthesized + * mouse cascade onto the nearby editable while the `touchstart`/`touchend` land on the thought-annotation + * overlay, so the editable's `onTouchEnd` never runs to `preventDefault`. On the pre-#4371 code the + * retargeted `mouseup` then runs `onMouseUp` with a stale, never-reset `offsetRef.current`, calling + * `setCaretOffset` -> `selection.set`, which focuses the editable and opens the keyboard even though + * `onMouseDown` preventDefaulted the native focus. See `caretFocusIsolated.ts` for the isolated-primitive + * proof. The stale-offset focus path is device- and version-independent, so the bug reproduces on both iOS + * 17 and iOS 18. The spec runs on both devices (see `wdio.browserstack.conf.ts`) and asserts the keyboard + * opens on each. */ import gesture from '../helpers/gesture' import getEditingText from '../helpers/getEditingText' @@ -19,27 +19,8 @@ import newThought from '../helpers/newThought' import waitForEditable from '../helpers/waitForEditable' import waitUntil from '../helpers/waitUntil' -/** Reads the iOS major version from the active session capabilities. */ -const getIOSMajorVersion = (): number => { - const caps = browser.capabilities as Record - const requested = (browser.requestedCapabilities ?? {}) as Record - const bstackOptions = - ((caps['bstack:options'] ?? requested['bstack:options']) as Record | undefined) ?? {} - const rawVersion = (caps['appium:platformVersion'] ?? - caps['platformVersion'] ?? - requested['appium:platformVersion'] ?? - requested['platformVersion'] ?? - bstackOptions['osVersion'] ?? - '') as string | number - return parseInt(String(rawVersion), 10) -} - describe('Caret', () => { - it('Keyboard opens on the edge tap only on iOS 18, not iOS 17', async () => { - const iosMajorVersion = getIOSMajorVersion() - // The touch-adjustment retargeting that #4394 depends on only fires on iOS 18's Safari. - const shouldReproduce = iosMajorVersion >= 18 - + it('Keyboard incorrectly opens on the right-edge tap of a non-cursor thought (#4394)', async () => { await newThought('Hello') const editable = await waitForEditable('Hello') @@ -90,11 +71,11 @@ describe('Caret', () => { 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 18 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. The retargeted mouseup then focuses the editable via the stale - // offset and the virtual keyboard opens. #4394. + // contact area (width/height/pressure). Safari's 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`. The retargeted `mouseup` then focuses the editable via the stale offset and the + // virtual keyboard opens. #4394. const tapX = Math.round(rect.x + rect.width + 4) const tapY = Math.round(rect.y + rect.height / 2) await browser.switchContext('NATIVE_APP') @@ -125,8 +106,9 @@ describe('Caret', () => { const keyboard = await isKeyboardShown() - // Positively assert the version-specific behavior: the edge tap opens the keyboard on iOS 18 (the - // #4394 reproduction, present on this pre-#4371 branch) but leaves it down on iOS 17. - expect(keyboard).toBe(shouldReproduce) + // The edge tap opens the keyboard on this pre-#4371 branch (the #4394 reproduction). The stale-offset + // focus path is version-independent, so this holds on both iOS 17 and iOS 18. It will flip to false + // once focus is correctly prevented for non-cursor thoughts. + expect(keyboard).toBe(true) }) }) diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts index 7c984456d16..9a9b3b26f36 100644 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -3,9 +3,9 @@ * * These specs strip away all em code and reproduce the bug against DOM primitives using a bare * contentEditable plus an adjacent non-focusable overlay that mimics the thought-annotation, driven by the - * exact finger-sized right-edge tap from the caretFocus spec. 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. + * exact finger-sized right-edge tap from the caretFocus spec. The touch-adjustment heuristic that redirects + * the synthesized mouse events onto the editable reproduces on both iOS 17 and iOS 18; this diagnostic runs + * on a single iOS 18 device (see `wdio.browserstack.conf.ts`) to avoid a redundant duplicate run. * * The root cause of #4394 on the pre-#4371 code is not a platform property of `mousedown`. In * `useEditMode.ts`, `offsetRef.current` is assigned when a thought that has the cursor is tapped, but it is @@ -18,7 +18,7 @@ * exactly what PR #4371 fixed by deleting `onMouseUp`/`offsetRef` and moving `setCaretOffset` into a * guarded `onMouseDown`. * - * Two configurations are probed, both under the identical iOS 18 edge tap. The reproduce spec models + * Two configurations are probed, both under the identical finger-sized edge tap. The reproduce spec models * pre-#4371: the editable's `mousedown` preventDefaults (blocking native focus) and a `mouseup` handler * calls `selection.set` using a stale, never-reset offset. Focus and the keyboard proceed anyway, pinning * the cause to the stale-offset `onMouseUp` with zero em code. The control spec models post-#4371: the diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index 6e9be9f5bb6..e86273c0f6a 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -17,11 +17,12 @@ 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. +// The #4394 caret focus regression reproduces on both iOS 17 and iOS 18 (the stale-offset focus path is +// version-independent), so this spec runs on a dedicated iOS 18 device and a dedicated iOS 17 device while +// the rest of the suite stays on the default iOS 17 device. 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. +// Isolated-primitive diagnostic that reproduces the same #4394 focus path against bare DOM primitives. const caretFocusIsolatedSpec = path.resolve(process.cwd(), 'src/e2e/iOS/__tests__/caretFocusIsolated.ts') const bstackOptions = { @@ -48,7 +49,7 @@ const suiteCapability = { }, } -// Run only the #4394 regression, which requires iOS 18 to reproduce. +// Run the #4394 regression (plus the isolated-primitive diagnostic) on an iOS 18 device. const caretFocusCapability = { ...baseConfig.baseCapabilities, specs: [caretFocusSpec, caretFocusIsolatedSpec], @@ -62,9 +63,9 @@ const caretFocusCapability = { }, } -// Run the #4394 caret focus spec on iOS 17 as well, to positively verify the bug is iOS-18-specific: -// the same edge tap that opens the keyboard on iOS 18 must leave it down on iOS 17. The isolated -// diagnostic depends on the iOS 18 retargeting, so it is not run here. +// Run the #4394 caret focus spec on iOS 17 as well, to positively verify the bug is not iOS-18-specific: +// the same edge tap that opens the keyboard on iOS 18 also opens it on iOS 17. The isolated diagnostic is +// covered by the iOS 18 device, so it is not duplicated here. const caretFocusIOS17Capability = { ...baseConfig.baseCapabilities, specs: [caretFocusSpec], From 7ed746b6ef47b6cae73b3206855ab5534e24dc3c Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 12:04:13 -0700 Subject: [PATCH 14/16] test(ios): run #4394 caret specs in the default iOS 17 suite Since #4394 reproduces on the default iOS 17 device, the dedicated iOS 18 and iOS 17 caret capabilities (and the suite excludes) are unnecessary. Revert wdio.browserstack.conf.ts to the single-device baseline so both caret specs run as part of the normal suite, and update the spec comments to match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretFocus.ts | 4 +- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 4 +- src/e2e/iOS/config/wdio.browserstack.conf.ts | 86 +++++--------------- 3 files changed, 23 insertions(+), 71 deletions(-) diff --git a/src/e2e/iOS/__tests__/caretFocus.ts b/src/e2e/iOS/__tests__/caretFocus.ts index 713687df2fb..657604bed13 100644 --- a/src/e2e/iOS/__tests__/caretFocus.ts +++ b/src/e2e/iOS/__tests__/caretFocus.ts @@ -7,8 +7,8 @@ * `setCaretOffset` -> `selection.set`, which focuses the editable and opens the keyboard even though * `onMouseDown` preventDefaulted the native focus. See `caretFocusIsolated.ts` for the isolated-primitive * proof. The stale-offset focus path is device- and version-independent, so the bug reproduces on both iOS - * 17 and iOS 18. The spec runs on both devices (see `wdio.browserstack.conf.ts`) and asserts the keyboard - * opens on each. + * 17 and iOS 18. The spec runs as part of the default iOS 17 suite (see `wdio.browserstack.conf.ts`) and + * asserts the keyboard opens. */ import gesture from '../helpers/gesture' import getEditingText from '../helpers/getEditingText' diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts index 9a9b3b26f36..0b512a84b4c 100644 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ b/src/e2e/iOS/__tests__/caretFocusIsolated.ts @@ -4,8 +4,8 @@ * These specs strip away all em code and reproduce the bug against DOM primitives using a bare * contentEditable plus an adjacent non-focusable overlay that mimics the thought-annotation, driven by the * exact finger-sized right-edge tap from the caretFocus spec. The touch-adjustment heuristic that redirects - * the synthesized mouse events onto the editable reproduces on both iOS 17 and iOS 18; this diagnostic runs - * on a single iOS 18 device (see `wdio.browserstack.conf.ts`) to avoid a redundant duplicate run. + * the synthesized mouse events onto the editable reproduces on both iOS 17 and iOS 18, so these run as part + * of the default iOS 17 suite (see `wdio.browserstack.conf.ts`). * * The root cause of #4394 on the pre-#4371 code is not a platform property of `mousedown`. In * `useEditMode.ts`, `offsetRef.current` is assigned when a thought that has the cursor is tapped, but it is diff --git a/src/e2e/iOS/config/wdio.browserstack.conf.ts b/src/e2e/iOS/config/wdio.browserstack.conf.ts index e86273c0f6a..5f2455acabc 100644 --- a/src/e2e/iOS/config/wdio.browserstack.conf.ts +++ b/src/e2e/iOS/config/wdio.browserstack.conf.ts @@ -17,68 +17,6 @@ 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 reproduces on both iOS 17 and iOS 18 (the stale-offset focus path is -// version-independent), so this spec runs on a dedicated iOS 18 device and a dedicated iOS 17 device while -// the rest of the suite stays on the default iOS 17 device. -const caretFocusSpec = path.resolve(process.cwd(), 'src/e2e/iOS/__tests__/caretFocus.ts') - -// Isolated-primitive diagnostic that reproduces the same #4394 focus path against bare DOM primitives. -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 the #4394 regression (plus the isolated-primitive diagnostic) on an iOS 18 device. -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)', - }, -} - -// Run the #4394 caret focus spec on iOS 17 as well, to positively verify the bug is not iOS-18-specific: -// the same edge tap that opens the keyboard on iOS 18 also opens it on iOS 17. The isolated diagnostic is -// covered by the iOS 18 device, so it is not duplicated here. -const caretFocusIOS17Capability = { - ...baseConfig.baseCapabilities, - specs: [caretFocusSpec], - 'appium:deviceName': 'iPhone 15 Plus', - 'appium:platformVersion': '17', - 'bstack:options': { - ...bstackOptions, - deviceName: 'iPhone 15 Plus', - osVersion: '17', - sessionName: 'iOS Safari Tests (caret focus, iOS 17)', - }, -} - /** * WDIO configuration for BrowserStack iOS testing. * Uses @wdio/browserstack-service for automatic tunnel management. @@ -96,12 +34,26 @@ export const config: WebdriverIO.Config = { user, key: process.env.BROWSERSTACK_ACCESS_KEY, - // Capabilities (per-capability specs/exclude is a WDIO runtime feature not covered by the strict type) + // Capabilities capabilities: [ - suiteCapability, - caretFocusCapability, - caretFocusIOS17Capability, - ] as WebdriverIO.Config['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, + }, + }, + ], // Services services: [ From ef4e91d8648b4639adcf992ad8e67b222e07d1b7 Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 14:39:37 -0700 Subject: [PATCH 15/16] test(ios): drop isolated caret spec so #4394 repro mirrors the fix PR Remove caretFocusIsolated.ts and its reference from the caretFocus.ts header. #4534 now carries only the primary caretFocus.ts reproduction spec, mirroring #4407 (the fix PR) on the earlier pre-#4371 branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caretFocus.ts | 7 +- src/e2e/iOS/__tests__/caretFocusIsolated.ts | 234 -------------------- 2 files changed, 3 insertions(+), 238 deletions(-) delete mode 100644 src/e2e/iOS/__tests__/caretFocusIsolated.ts diff --git a/src/e2e/iOS/__tests__/caretFocus.ts b/src/e2e/iOS/__tests__/caretFocus.ts index 657604bed13..974f95c7571 100644 --- a/src/e2e/iOS/__tests__/caretFocus.ts +++ b/src/e2e/iOS/__tests__/caretFocus.ts @@ -5,10 +5,9 @@ * overlay, so the editable's `onTouchEnd` never runs to `preventDefault`. On the pre-#4371 code the * retargeted `mouseup` then runs `onMouseUp` with a stale, never-reset `offsetRef.current`, calling * `setCaretOffset` -> `selection.set`, which focuses the editable and opens the keyboard even though - * `onMouseDown` preventDefaulted the native focus. See `caretFocusIsolated.ts` for the isolated-primitive - * proof. The stale-offset focus path is device- and version-independent, so the bug reproduces on both iOS - * 17 and iOS 18. The spec runs as part of the default iOS 17 suite (see `wdio.browserstack.conf.ts`) and - * asserts the keyboard opens. + * `onMouseDown` preventDefaulted the native focus. The stale-offset focus path is device- and + * version-independent, so the bug reproduces on both iOS 17 and iOS 18. This spec runs as part of the + * default iOS suite and asserts the keyboard opens. */ import gesture from '../helpers/gesture' import getEditingText from '../helpers/getEditingText' diff --git a/src/e2e/iOS/__tests__/caretFocusIsolated.ts b/src/e2e/iOS/__tests__/caretFocusIsolated.ts deleted file mode 100644 index 0b512a84b4c..00000000000 --- a/src/e2e/iOS/__tests__/caretFocusIsolated.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * IOS Safari isolated-primitive diagnostics for #4394. - * - * These specs strip away all em code and reproduce the bug against DOM primitives using a bare - * contentEditable plus an adjacent non-focusable overlay that mimics the thought-annotation, driven by the - * exact finger-sized right-edge tap from the caretFocus spec. The touch-adjustment heuristic that redirects - * the synthesized mouse events onto the editable reproduces on both iOS 17 and iOS 18, so these run as part - * of the default iOS 17 suite (see `wdio.browserstack.conf.ts`). - * - * The root cause of #4394 on the pre-#4371 code is not a platform property of `mousedown`. In - * `useEditMode.ts`, `offsetRef.current` is assigned when a thought that has the cursor is tapped, but it is - * never reset back to null. `onMouseUp` then reads that stale offset and calls `setCaretOffset` -> - * `selection.set`, which focuses the editable and opens the keyboard. So the focus that #4394 complains - * about is driven programmatically from `onMouseUp` using a stale offset, not by the native focus default - * of the tap. On the #4394 edge tap the editable's `onMouseDown` runs its else branch and calls - * `e.preventDefault()`, which does block the native focus default. The keyboard still opens only because - * the retargeted `mouseup` fires on the editable and the stale-offset `selection.set` focuses it. This is - * exactly what PR #4371 fixed by deleting `onMouseUp`/`offsetRef` and moving `setCaretOffset` into a - * guarded `onMouseDown`. - * - * Two configurations are probed, both under the identical finger-sized edge tap. The reproduce spec models - * pre-#4371: the editable's `mousedown` preventDefaults (blocking native focus) and a `mouseup` handler - * calls `selection.set` using a stale, never-reset offset. Focus and the keyboard proceed anyway, pinning - * the cause to the stale-offset `onMouseUp` with zero em code. The control spec models post-#4371: the - * editable's `mousedown` preventDefaults and there is no `mouseup` selection at all. Focus is blocked and - * the keyboard stays down, confirming that removing the stale-offset `onMouseUp` (what #4371 did) fixes the - * bug. - */ -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 - __fixMouseupFired?: boolean - __fixSelectionSet?: boolean -} - -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 - blocking the native focus default and recording whether it fired and was prevented. - * - * When staleOffsetMouseup is set, the editable additionally gets a mouseup handler that models the - * pre-#4371 `onMouseUp`: it reads a stale, never-reset offset and sets the DOM selection inside the - * editable exactly as `device/selection.set` does (removeAllRanges + addRange, no explicit focus()), - * which focuses the contentEditable. When it is not set, there is no mouseup selection, modeling the - * post-#4371 code where `onMouseUp` was removed entirely. - */ - const injectFixture = (staleOffsetMouseup: boolean) => - browser.execute(withMouseup => { - const w = window as unknown as FixWindow - document.body.innerHTML = '' - w.__fixFocused = false - w.__fixMousedownFired = false - w.__fixMousedownPrevented = null - w.__fixMouseupFired = false - w.__fixSelectionSet = false - - 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;' - - // Models useEditMode's `else` branch: preventDefault blocks the native focus default of the tap. - editable.addEventListener('mousedown', e => { - e.preventDefault() - w.__fixMousedownFired = true - w.__fixMousedownPrevented = e.defaultPrevented - }) - editable.addEventListener('focus', () => { - w.__fixFocused = true - }) - - if (withMouseup) { - // A stale, never-reset offset, standing in for `offsetRef.current` which useEditMode assigns when a - // cursor thought is tapped and never resets to null. - const staleOffset = 2 - - // Models pre-#4371 `onMouseUp` -> `setCaretOffset` -> `selection.set`: set the DOM selection inside - // the editable using the stale offset. This focuses the contentEditable even though the mousedown - // default was prevented, which is the #4394 focus leak. - editable.addEventListener('mouseup', () => { - w.__fixMouseupFired = true - const textNode = editable.firstChild - if (!textNode) return - const clamped = Math.min(staleOffset, textNode.textContent?.length ?? 0) - const range = document.createRange() - range.setStart(textNode, clamped) - range.collapse(true) - const sel = window.getSelection() - if (!sel) return - sel.removeAllRanges() - sel.addRange(range) - w.__fixSelectionSet = true - }) - } - - wrap.appendChild(editable) - wrap.appendChild(overlay) - document.body.appendChild(wrap) - }, staleOffsetMouseup) - - /** 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) - // Reset only the transient probe flags. The stale offset intentionally persists, mirroring - // offsetRef.current never being reset in useEditMode. - await browser.execute(() => { - const w = window as unknown as FixWindow - w.__fixFocused = false - w.__fixMousedownFired = false - w.__fixMouseupFired = false - w.__fixSelectionSet = 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, - mouseupFired: w.__fixMouseupFired === true, - selectionSet: w.__fixSelectionSet === true, - } - }) - - /** Performs the finger-sized tap ~4px past the editable's right edge that triggers the #4394 retargeting. */ - const edgeTap = async (webviewContext: string, rect: { x: number; y: number; width: number; height: number }) => { - await nativeTap(webviewContext, Math.round(rect.x + rect.width + 4), Math.round(rect.y + rect.height / 2), true) - await browser.pause(600) - } - - it('reproduce: stale-offset onMouseUp selection focuses the editable despite a prevented mousedown', 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) - await edgeTap(webviewContext, rect) - - const probes = await readProbes() - const keyboard = await isKeyboardShown() - - // The mousedown fired on the retargeted editable and its native focus default was successfully prevented. - expect(probes.mousedownFired).toBe(true) - expect(probes.mousedownPrevented).toBe(true) - // The retargeted mouseup also fired on the editable and ran the stale-offset selection.set. - expect(probes.mouseupFired).toBe(true) - expect(probes.selectionSet).toBe(true) - // ...yet the editable focused and the keyboard opened anyway - driven by the programmatic selection, - // not the native tap default. This isolates #4394 to the stale-offset onMouseUp with zero em code. - expect(probes.focused).toBe(true) - expect(keyboard).toBe(true) - }) - - it('control: with no onMouseUp selection (post-#4371), the prevented mousedown keeps focus down', 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) - await edgeTap(webviewContext, rect) - - const probes = await readProbes() - const keyboard = await isKeyboardShown() - - // The same retargeting occurs: the mousedown fires on the editable and is prevented. - expect(probes.mousedownFired).toBe(true) - expect(probes.mousedownPrevented).toBe(true) - // With the stale-offset onMouseUp removed (what #4371 did), nothing focuses the editable... - expect(probes.selectionSet).toBe(false) - // ...so focus is blocked and the keyboard stays down. - expect(probes.focused).toBe(false) - expect(keyboard).toBe(false) - }) -}) From e510862aa7ac123d98feaca148178ec65c4d5fed Mon Sep 17 00:00:00 2001 From: Ethan James Date: Wed, 8 Jul 2026 14:52:48 -0700 Subject: [PATCH 16/16] test(ios): move #4394 caret focus repro into caret.ts Fold the caretFocus.ts reproduction spec back into the Caret describe block in caret.ts and delete the standalone file. No behavior change; the spec still asserts the keyboard opens on the pre-#4371 branch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/e2e/iOS/__tests__/caret.ts | 102 +++++++++++++++++++++++++ src/e2e/iOS/__tests__/caretFocus.ts | 113 ---------------------------- 2 files changed, 102 insertions(+), 113 deletions(-) delete mode 100644 src/e2e/iOS/__tests__/caretFocus.ts diff --git a/src/e2e/iOS/__tests__/caret.ts b/src/e2e/iOS/__tests__/caret.ts index 29e773dc8f0..578bfcbd3e6 100644 --- a/src/e2e/iOS/__tests__/caret.ts +++ b/src/e2e/iOS/__tests__/caret.ts @@ -251,4 +251,106 @@ describe('Caret', () => { expect(selectionTextContent).toBe('new') expect(childrenTexts).toEqual(['foo', 'bar']) }) + + /** + * Reproduction of #4394. Tapping ~4px past the right edge of a non-cursor thought incorrectly opens the + * virtual keyboard. Safari's touch-adjustment heuristic retargets the synthesized mouse cascade onto the + * nearby editable while the `touchstart`/`touchend` land on the thought-annotation overlay, so the + * editable's `onTouchEnd` never runs to `preventDefault`. On the pre-#4371 code the retargeted `mouseup` + * then runs `onMouseUp` with a stale, never-reset `offsetRef.current`, calling `setCaretOffset` -> + * `selection.set`, which focuses the editable and opens the keyboard even though `onMouseDown` + * preventDefaulted the native focus. The stale-offset focus path is device- and version-independent, so + * the bug reproduces on both iOS 17 and iOS 18. + */ + it('Keyboard incorrectly opens on the right-edge tap of a non-cursor thought (#4394)', 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. Priming while + // "Hello" has the cursor is also what leaves offsetRef.current set (and never reset) on pre-#4371. + 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). Safari's 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`. The retargeted `mouseup` then focuses the editable via the stale offset and the + // virtual keyboard opens. #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) + + const keyboard = await isKeyboardShown() + + // The edge tap opens the keyboard on this pre-#4371 branch (the #4394 reproduction). The stale-offset + // focus path is version-independent, so this holds on both iOS 17 and iOS 18. It will flip to false + // once focus is correctly prevented for non-cursor thoughts. + expect(keyboard).toBe(true) + }) }) diff --git a/src/e2e/iOS/__tests__/caretFocus.ts b/src/e2e/iOS/__tests__/caretFocus.ts deleted file mode 100644 index 974f95c7571..00000000000 --- a/src/e2e/iOS/__tests__/caretFocus.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * IOS Safari caret focus reproduction for #4394. Tapping ~4px past the right edge of a non-cursor thought - * incorrectly opens the virtual keyboard. Safari's touch-adjustment heuristic retargets the synthesized - * mouse cascade onto the nearby editable while the `touchstart`/`touchend` land on the thought-annotation - * overlay, so the editable's `onTouchEnd` never runs to `preventDefault`. On the pre-#4371 code the - * retargeted `mouseup` then runs `onMouseUp` with a stale, never-reset `offsetRef.current`, calling - * `setCaretOffset` -> `selection.set`, which focuses the editable and opens the keyboard even though - * `onMouseDown` preventDefaulted the native focus. The stale-offset focus path is device- and - * version-independent, so the bug reproduces on both iOS 17 and iOS 18. This spec runs as part of the - * default iOS suite and asserts the keyboard opens. - */ -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('Keyboard incorrectly opens on the right-edge tap of a non-cursor thought (#4394)', 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. Priming while - // "Hello" has the cursor is also what leaves offsetRef.current set (and never reset) on pre-#4371. - 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). Safari's 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`. The retargeted `mouseup` then focuses the editable via the stale offset and the - // virtual keyboard opens. #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) - - const keyboard = await isKeyboardShown() - - // The edge tap opens the keyboard on this pre-#4371 branch (the #4394 reproduction). The stale-offset - // focus path is version-independent, so this holds on both iOS 17 and iOS 18. It will flip to false - // once focus is correctly prevented for non-cursor thoughts. - expect(keyboard).toBe(true) - }) -})