Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/e2e/iOS/__tests__/caret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('Caret', () => {
await hideKeyboardByTappingDone()

const editableNodeHandle = await waitForEditable('foo')
await tap(editableNodeHandle, { y: 60, x: 20 })
await tap(editableNodeHandle, { x: 20 })

await waitUntil(isKeyboardShown)
const selectionTextContent = await getSelection().focusNode?.textContent
Expand All @@ -40,7 +40,7 @@ describe('Caret', () => {
await newThought('bar', { insertNewSubthought: true })

const editableNodeHandle = await waitForEditable('foo')
await tap(editableNodeHandle, { y: 60, x: 20 })
await tap(editableNodeHandle, { x: 20 })

await waitUntil(async () => (await getEditingText()) === 'foo')
const selectionTextContent = await getSelection().focusNode?.textContent
Expand All @@ -53,7 +53,7 @@ describe('Caret', () => {
await hideKeyboardByTappingDone()

const editableNodeHandle = await waitForEditable('foo')
await tap(editableNodeHandle)
await tap(editableNodeHandle, { x: 20 })

const selectionTextContent = await getSelection().focusNode?.textContent
expect(selectionTextContent).toBe(null)
Expand All @@ -71,7 +71,7 @@ describe('Caret', () => {
await newThought('d', { insertNewSubthought: true })

const editableNodeHandle = await waitForEditable('c')
await tap(editableNodeHandle, { y: 60, x: 20 })
await tap(editableNodeHandle, { x: 20 })
await waitUntil(async () => (await getEditingText()) === 'c')

const selectionTextContent = await getSelection().focusNode?.textContent
Expand All @@ -91,7 +91,7 @@ describe('Caret', () => {
await clickThought('c')

const editableNodeHandle = await waitForEditable('d')
await tap(editableNodeHandle, { y: 60, x: 20 })
await tap(editableNodeHandle, { x: 20 })
await waitUntil(async () => (await getEditingText()) !== 'c')

const editingText = await getEditingText()
Expand All @@ -111,7 +111,7 @@ describe('Caret', () => {
await clickThought('c')

const editableNodeHandle = await waitForEditable('d')
await tap(editableNodeHandle, { y: 60, x: 20 })
await tap(editableNodeHandle, { x: 20 })

await waitUntil(async () => (await getEditingText()) === 'd')
const selectionTextContent = await getSelection().focusNode?.textContent
Expand Down Expand Up @@ -175,7 +175,7 @@ describe('Caret', () => {
segmentLength: elementRect.width,
})

await tap(editableNodeHandle, { y: 60, x: 20 })
await tap(editableNodeHandle, { x: 20 })

const editingText = await getEditingText()
expect(editingText).toBe('foo')
Expand Down
38 changes: 38 additions & 0 deletions src/e2e/iOS/__tests__/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import $ from '../helpers/$'
import getEditingText from '../helpers/getEditingText'
import paste from '../helpers/paste'
import tap from '../helpers/tap'

/** Retrieve the innerHTML of the first note on the page. Assumes that there will be only a single note. */
const getFirstNoteText = async () => (await $('[aria-label="note-editable"]')).getHTML({ includeSelectorTag: false })

it('Can change the background color of a thought that already has the same background color applied to part of its text, then change the text color', async () => {
const content = '- some <font color="#000000" style="background-color: rgb(255, 87, 61);">formatted</font> text'
await paste(content)

await tap('[data-testid="toolbar-icon"][aria-label="Text Color"]', { horizontalTapLine: 'center' })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't the default horizontalTapLine work?

Color tests should not have to worry about where to tap the button.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value for horizontalTapLine is left. However, for toolbar icon buttons, calculating the tap coordinates from the left edge does not consistently target the button accurately. To address this, the center option was introduced, which calculates the tap coordinates at the horizontal center of the button, resulting in more reliable and accurate tap interactions.

Color tests should not have to worry about where to tap the button.

You're right about this part. I was thinking if we want to keep this functionality within tap, we could introduce a new option that indicates the target is a toolbar icon, allowing the tap coordinates to be calculated accordingly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about making center the global default? This more closely matches user semantics. If you tell a user to tap on a button, they're more likely to tap somewhere near the center than tap the exact edge.

The question is whether that will break other tests. If other tests depend on the tap occurring on the left edge (for example to set the cursor at the beginning of the thought), then arguably those tests should make that dependency explicit by specifying horizontalTapLine.

Please try to avoid phrases like "reliable" or "accurate" without going into the details. We're dealing with exact pixels. The button is rendered in the same place every time. You should understand exactly why left edge tap breaks on toolbar buttons. Only then can we determine the best solution. If we lack understanding, then our solution may be based on false assumptions and we may fail to apply the lesson properly in future scenarios.

I was thinking if we want to keep this functionality within tap, we could introduce a new option that indicates the target is a toolbar icon, allowing the tap coordinates to be calculated accordingly.

tap is a general function. General functions should not be concerned with specific usage. Your gut feeling should tell you that parameterizing general functions with context-dependent behavior is a bad idea. At least horizontalTapLine is general.

await tap('[aria-label="background color swatches"] [aria-label="red"]', { horizontalTapLine: 'center' })
await tap('[aria-label="text color swatches"] [aria-label="red"]', { horizontalTapLine: 'center' })

const thought = await getEditingText()
expect(thought).toBe('<font color="#ff573d">some formatted text</font>')
})

it('Can change the background color of a note that already has the same background color applied to part of its text, then change the text color', async () => {
await paste(
`
- a
- =note
- Multi-word <font color="#000000" style="background-color: rgb(255, 87, 61);">note</font>
`,
)

await tap('[data-testid="toolbar-icon"][aria-label="Note"]', { horizontalTapLine: 'center' })

await tap('[data-testid="toolbar-icon"][aria-label="Text Color"]', { horizontalTapLine: 'center' })
await tap('[aria-label="background color swatches"] [aria-label="red"]', { horizontalTapLine: 'center' })
await tap('[aria-label="text color swatches"] [aria-label="red"]', { horizontalTapLine: 'center' })

const result = await getFirstNoteText()
expect(result).toBe('<font color="#ff573d">Multi-word note</font>')
})
5 changes: 2 additions & 3 deletions src/e2e/iOS/__tests__/split.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,9 @@ describe('Split', () => {
await clickThought('puppeteer')

const editableNodeHandle = await waitForEditable('web scraping')
await clickThought('web scraping')

await tap(editableNodeHandle, { y: 60, x: 25 })
await tap(editableNodeHandle, { y: 60, x: 25 })
await tap(editableNodeHandle, { x: 25 })
await tap(editableNodeHandle, { x: 25 })
await tapReturnKey()

const offset = await getSelection()?.focusOffset
Expand Down
2 changes: 1 addition & 1 deletion src/e2e/iOS/config/wdio.browserstack.conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const config: WebdriverIO.Config = {
debug: false,
networkLogs: false,
consoleLogs: 'errors',
idleTimeout: 60,
idleTimeout: 300,
},
},
],
Expand Down
94 changes: 61 additions & 33 deletions src/e2e/iOS/helpers/gesture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,52 +17,80 @@ interface PointerAction {
origin?: string
}

/** Apply gesture action for the given path. */
/** Performs a touch swipe along a viewport-relative path, translated to screen coordinates via the WebView origin. */
const gesture = async (path: Gesture, { xStart, yStart, segmentLength = 60, waitMs = 200 }: GestureOptions = {}) => {
if (!xStart || !yStart) {
const windowSize = await browser.getWindowSize()
xStart = xStart ?? windowSize!.width / 3
yStart = yStart ?? windowSize!.height / 2
}
const { scrollBefore, defaultX, defaultY } = await browser.execute(() => ({
scrollBefore: { x: window.scrollX, y: window.scrollY },
defaultX: window.innerWidth / 3,
defaultY: window.innerHeight / 2,
}))

// Build actions array for performActions
// Safari/XCUITest doesn't support the DELETE /actions endpoint (releaseActions)
// which WebDriverIO's action().perform() calls automatically after performing
const pointerActions: PointerAction[] = [
{ type: 'pointerMove', duration: 0, x: Math.round(xStart), y: Math.round(yStart), origin: 'viewport' },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: waitMs },
]
xStart = xStart ?? defaultX
yStart = yStart ?? defaultY

// Build move actions for each direction in the path
const viewportPoints: { x: number; y: number }[] = [{ x: xStart, y: yStart }]
let currentX = xStart
let currentY = yStart

for (const direction of Array.from(path) as Direction[]) {
currentX = currentX + (direction === 'r' ? +segmentLength : direction === 'l' ? -segmentLength : 0)
currentY = currentY + (direction === 'd' ? +segmentLength : direction === 'u' ? -segmentLength : 0)
viewportPoints.push({ x: currentX, y: currentY })
}

const oldContext = ((await browser.getContext()) as string) || 'NATIVE_APP'
try {
await browser.switchContext('NATIVE_APP')

const webContainer = await browser.$('//XCUIElementTypeOther[@name="em"]').getElement()
const { x: webOriginX, y: webOriginY } = await browser.getElementRect(webContainer.elementId)

pointerActions.push({
type: 'pointerMove',
duration: waitMs,
x: Math.round(currentX),
y: Math.round(currentY),
origin: 'viewport',
/** Translate WebView viewport coordinates to device screen coordinates. */
const toScreen = (p: { x: number; y: number }) => ({
x: Math.round(p.x + webOriginX),
y: Math.round(p.y + webOriginY),
})
pointerActions.push({ type: 'pause', duration: waitMs })
}

pointerActions.push({ type: 'pointerUp', button: 0 })
const start = toScreen(viewportPoints[0])
const pointerActions: PointerAction[] = [
{ type: 'pointerMove', duration: 0, x: start.x, y: start.y, origin: 'viewport' },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: waitMs },
]

for (let i = 1; i < viewportPoints.length; i++) {
const screen = toScreen(viewportPoints[i])
pointerActions.push({
type: 'pointerMove',
duration: waitMs,
x: screen.x,
y: screen.y,
origin: 'viewport',
})
pointerActions.push({ type: 'pause', duration: waitMs })
}

// Use performActions directly to avoid the automatic releaseActions call
await browser.performActions([
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: pointerActions,
},
])
pointerActions.push({ type: 'pointerUp', button: 0 })

await browser.performActions([
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: pointerActions,
},
])
} finally {
await browser.switchContext(oldContext)
await browser.execute(({ x, y }) => window.scrollTo(x, y), scrollBefore)
await browser.waitUntil(
async () => {
const current = await browser.execute(() => ({ x: window.scrollX, y: window.scrollY }))
return current.x === scrollBefore.x && current.y === scrollBefore.y
},
{ timeout: 3000, timeoutMsg: 'Failed to restore scroll position after gesture' },
)
}
}

export default gesture
14 changes: 10 additions & 4 deletions src/e2e/iOS/helpers/hideKeyboardByTappingDone.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
/**
* Hide iOS keyboard by tapping done button above the keyboard.
* Uses the global browser object from WDIO.
*/
/** Dismisses the iOS keyboard via Safari's native "Done" button and blurs the active element to ensure subsequent taps trigger a fresh focus event. */
const hideKeyboardByTappingDone = async () => {
const oldContext = ((await browser.getContext()) as string) || 'NATIVE_APP'
await browser.switchContext('NATIVE_APP')
const done = await browser.$('//XCUIElementTypeButton[@name="Done"]').getElement()
await done.click()
await done.waitForExist({ reverse: true })
await browser.switchContext(oldContext)

await browser.execute(() => {
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens on a physical iPhone when the keyboard is closed by tapping Done? Is document.activeElement already cleared?

We want to make sure our tests don't deviate from actual behavior.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean when you say we don't want tests to deviate from actual behavior in this case?

@raineorshine raineorshine Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that we don't want integration tests to do anything that the user can't do with their device. The user closes the keyboard by tapping the Done button and they don't have access to blur(). So why is the test doing something "special" that the user can't do?

We don't want to give integration tests special powers. Then we end up having test coverage of those special powers instead of test coverage over the actual behavior that the user is capable of.


return new Promise<void>(resolve => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
})
}

export default hideKeyboardByTappingDone
Loading
Loading