diff --git a/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs b/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs index 210e828af8f..b1821fd0683 100644 --- a/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-live-input-render.test.mjs @@ -112,8 +112,11 @@ afterAll(async () => { * Settled is "the probe registered" or "the page reported a fault", because those are the two * outcomes and waiting only for the first turns the defect into a 60s timeout that names nothing. */ -async function openProbe() { - const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) +async function openProbe({ userAgent } = {}) { + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, + ...(userAgent ? { userAgent } : {}) + }) await page.addInitScript(installShellDouble, { version: bridgeVersion, sessionId: 'live-input-session', @@ -322,6 +325,104 @@ describeRender( await page.close() }, 300_000) + describe('under an Android keyboard, which composes every word it types', () => { + // The OTA shell's WebView. Its keyboards hold a composing region over the Latin word being + // typed, so every input event mid-word says `isComposing: true`; native Android reports no + // range at all, and there each ASCII keystroke reaches the terminal as it is typed. + const ANDROID_WEBVIEW_USER_AGENT = + 'Mozilla/5.0 (Linux; Android 16; Pixel 9 Pro Build/BP2A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/140.0.0.0 Mobile Safari/537.36' + const sent = (page) => page.evaluate(() => globalThis.__orcaLiveInputProbe.sent()) + + /** One composition step through the browser's own IME path, which fires the DOM composition events. */ + async function compose(input, text) { + await input.send('Input.imeSetComposition', { + text, + selectionStart: text.length, + selectionEnd: text.length + }) + } + + async function waitForField(page, value) { + await page.waitForFunction( + ([id, expected]) => document.getElementById(id)?.value === expected, + [LIVE_INPUT_FIELD_ID, value], + { timeout: 30_000, polling: 50 } + ) + } + + it('sends each letter of a composed word after a slash as it is typed', async () => { + // The reported shape: `/tui` in a Codex terminal, where the `/` arrived and `tui` did not + // until Enter. The keyboard commits `/` outright and opens a composition for the letters. + const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT }) + await page.focus(`#${LIVE_INPUT_FIELD_ID}`) + const input = await page.context().newCDPSession(page) + await page.keyboard.type('/') + await waitForField(page, '/') + + const afterEachStep = [] + for (const text of ['t', 'tu', 'tui']) { + await compose(input, text) + await waitForField(page, `/${text}`) + afterEachStep.push(await sent(page)) + } + await input.send('Input.insertText', { text: 'tui' }) + await waitForField(page, '/tui') + + expect(afterEachStep).toEqual([ + ['/', 't'], + ['/', 't', 'u'], + ['/', 't', 'u', 'i'] + ]) + expect(await sent(page)).toEqual(['/', 't', 'u', 'i']) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it('erases and retypes a word the keyboard corrects when it commits', async () => { + const { errors, page } = await openProbe({ userAgent: ANDROID_WEBVIEW_USER_AGENT }) + await page.focus(`#${LIVE_INPUT_FIELD_ID}`) + const input = await page.context().newCDPSession(page) + for (const text of ['t', 'te', 'teh']) { + await compose(input, text) + await waitForField(page, text) + } + + await input.send('Input.insertText', { text: 'the' }) + await waitForField(page, 'the') + + await page.waitForFunction( + () => globalThis.__orcaLiveInputProbe.sent().length === 4, + undefined, + { + timeout: 30_000, + polling: 50 + } + ) + expect(await sent(page)).toEqual(['t', 'e', 'h', '\u007f\u007fhe']) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + + it("still holds a composition off Android, where it is the text system's marked text", async () => { + // The guard: an iOS WebView composes only what native iOS marks, pinyin before conversion + // among it, and that is not text yet on either side of the bridge. + const { errors, page } = await openProbe({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' + }) + await page.focus(`#${LIVE_INPUT_FIELD_ID}`) + const input = await page.context().newCDPSession(page) + for (const text of ['n', 'ni']) { + await compose(input, text) + await waitForField(page, text) + } + + expect(await sent(page)).toEqual([]) + expect(errors).toEqual([]) + await page.close() + }, 300_000) + }) + describe('in buffered mode, where the field holds the draft until Enter', () => { const bufferedValue = (page) => page.evaluate((id) => document.getElementById(id)?.value ?? null, BUFFERED_FIELD_ID) diff --git a/mobile/src/terminal/terminal-live-preedit-mirror.ts b/mobile/src/terminal/terminal-live-preedit-mirror.ts index 29c903f4366..2bc165a625c 100644 --- a/mobile/src/terminal/terminal-live-preedit-mirror.ts +++ b/mobile/src/terminal/terminal-live-preedit-mirror.ts @@ -21,9 +21,10 @@ export type TerminalLiveMirrorStep = { * ever classified. That is what makes this hold for input methods nobody tested. * * `composing` is undefined only where the platform reports no range at all — - * today React Native Android. The fallback holds the trailing non-ASCII run: a - * conversion IME always leaves one and ASCII keeps its zero-latency echo. It - * enumerates nothing, and it cannot see an ASCII preedit — only a report can. + * today React Native Android and the page on Android. The fallback holds the + * trailing non-ASCII run: a conversion IME always leaves one and ASCII keeps its + * zero-latency echo. It enumerates nothing, and it cannot see an ASCII preedit — + * only a report can. */ function heldPreeditLength( fieldCodePoints: readonly string[], diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 87ad2b64292..df693be45be 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -121,6 +121,7 @@ function createTerminalLiveInputCommitHarness({ describe('terminal live input commit hook', () => { afterEach(() => { vi.useRealTimers() + vi.unstubAllGlobals() }) it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => { @@ -168,6 +169,41 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual([]) }) + it('Given an Android WebView composing each word When letters follow a slash Then each reaches the terminal as typed', async () => { + // Given: the page's field event is the DOM's, and an Android keyboard composes Latin words + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + changeLiveInput(handlers, '/', false) + await vi.waitFor(() => expect(sent).toHaveLength(1)) + for (const fieldText of ['/t', '/tu', '/tui']) { + changeLiveInput(handlers, fieldText, true) + // Each keystroke on its own, so a held letter cannot hide inside a later batch. + await vi.waitFor(() => expect(sent).toHaveLength(fieldText.length)) + } + + // Then: what native Android sends for the same keys, which report no range there + await vi.waitFor(() => expect(sent).toEqual(['/', 't', 'u', 'i'])) + }) + + it('Given an Android WebView composing Hangul When steps arrive Then the non-ASCII run still settles on the timer', async () => { + // Given + vi.useFakeTimers() + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Linux; Android 16; wv) Chrome/140' }) + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + for (const fieldText of ['ㅎ', '하', '한']) { + changeLiveInput(handlers, fieldText, true) + await vi.advanceTimersByTimeAsync(50) + } + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => { // Given vi.useFakeTimers() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 55bc23c3dcc..237a78ca12a 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -27,6 +27,16 @@ type TerminalLiveInputChangeEvent = { } } +/** Android keyboards compose every Latin word; report no range, as native Android does. */ +function reportedLiveInputComposing( + nativeEvent: TerminalLiveInputChangeEvent['nativeEvent'] +): boolean | undefined { + if (globalThis.navigator?.userAgent?.includes('Android')) { + return undefined + } + return nativeEvent.isComposing +} + type TerminalLiveInputCommitOptions = { readonly activeHandle: string | null readonly activeHandleRef: RefObject @@ -148,7 +158,7 @@ export function useTerminalLiveInputCommit({ void applyLiveInputMirror( activeHandle, normalizeTerminalTextInput(nativeEvent.text), - nativeEvent.isComposing + reportedLiveInputComposing(nativeEvent) ) }, [