orca/tests/e2e/settings-display-name-ime.s...

163 lines
6.6 KiB
TypeScript

/**
* Regression test for Hangul IME composition in the repository Display Name
* setting (jamo decomposition: typing 가나다 produced ㄱㅏㄴㅏㄷㅏ).
*
* Why CDP: Playwright's keyboard API cannot drive IME composition. The CDP
* `Input.imeSetComposition` command goes through Blink's real composition
* pipeline, so a controlled-input value reset mid-composition cancels the
* composition exactly like a real OS IME session.
*/
import type { CDPSession, Locator, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { getStoreState, waitForSessionReady } from './helpers/store'
import type { Repo } from '../../src/shared/types'
async function openRepoSettings(page: Page, repoId: string): Promise<void> {
await page.evaluate((repoId) => {
const state = window.__store!.getState()
state.openSettingsTarget({ pane: 'repo', repoId })
state.openSettingsPage()
}, repoId)
await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 })
// Why: first-run announcements can cover the settings pane on fresh profiles.
const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' })
if (await maybeLaterButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
await maybeLaterButton.click()
}
}
/**
* Minimal 2-set Korean IME combination table for the key sequence ㄱㅏㄴㅏㄷㅏ.
* Returns the next composition text, plus the syllable to commit when the new
* key cannot join the pending composition (간 + ㅏ → commit 가, compose 나).
*/
function combineJamo(pending: string, key: string): { commit?: string; compose: string } {
const joins: Record<string, { commit?: string; compose: string }> = {
'ㄱ+ㅏ': { compose: '가' },
'ㄴ+ㅏ': { compose: '나' },
'ㄷ+ㅏ': { compose: '다' },
'가+ㄴ': { compose: '간' },
'간+ㅏ': { commit: '가', compose: '나' },
'나+ㄷ': { compose: '낟' },
'낟+ㅏ': { commit: '나', compose: '다' }
}
if (!pending) {
return { compose: key }
}
// Why: like a real IME, a non-joinable key commits the pending text and
// starts a fresh composition with just the new key.
return joins[`${pending}+${key}`] ?? { commit: pending, compose: key }
}
/**
* Emulates a real 2-set Korean IME typing 가나다 slowly (keys: ㄱㅏㄴㅏㄷㅏ).
*
* Adaptive on purpose: a real OS IME is cancelled (ImeCancelComposition) when
* the page rewrites the text backing its composition — exactly what a
* controlled React input does when its store echo is async. That browser→IME
* channel is not observable from page JS (no compositionend fires), so the
* emulator detects the same condition directly: an input event whose value the
* page clobbered right afterwards. After a clobber the IME restarts from an
* empty state on the next key, which is what turned 가나다 into ㄱㅏㄴㅏㄷㅏ.
*
* The per-key delay models slow human typing: it gives the async updateRepo
* store echo time to land between keystrokes, which is exactly the condition
* that produced the full jamo decomposition.
*/
async function typeHangulGanadaSlowly(
session: CDPSession,
page: Page,
input: Locator
): Promise<void> {
await input.evaluate((el) => {
const w = window as unknown as { __imeClobbered?: boolean }
w.__imeClobbered = false
el.addEventListener('input', () => {
const seen = (el as HTMLInputElement).value
// Why: React restores the controlled value after the input event's
// dispatch but later than its microtasks, so poll on a macrotask.
setTimeout(() => {
if ((el as HTMLInputElement).value !== seen) {
w.__imeClobbered = true
}
}, 0)
})
})
const takeClobbered = (): Promise<boolean> =>
page.evaluate(() => {
const w = window as unknown as { __imeClobbered?: boolean }
const clobbered = w.__imeClobbered === true
w.__imeClobbered = false
return clobbered
})
let committed = ''
let pending = ''
for (const key of ['ㄱ', 'ㅏ', 'ㄴ', 'ㅏ', 'ㄷ', 'ㅏ']) {
if (await takeClobbered()) {
committed = await input.inputValue()
pending = ''
}
const { commit, compose } = combineJamo(pending, key)
if (commit) {
committed += commit
}
const compositionText = `${committed}${compose}`
await session.send('Input.imeSetComposition', {
text: compositionText,
selectionStart: compositionText.length,
selectionEnd: compositionText.length
})
pending = compose
// Slow typing: let the async store echo land before the next key.
await page.waitForTimeout(200)
}
// Why: a real IME commits the pending syllable (space/enter) at the end of a
// word, firing compositionend. Without this final commit the controlled
// input stays in composing state, so the component's defer-until-compositionend
// persist never runs. insertText replaces the composing region in place, so it
// finalizes to the same text rather than double-committing.
await session.send('Input.insertText', { text: committed + pending })
}
test.describe('Repository Display Name IME composition', () => {
test('keeps Hangul syllables composed while typing slowly', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
const repos = await getStoreState<Repo[]>(orcaPage, 'repos')
expect(repos.length).toBeGreaterThan(0)
const repo = repos[0]
await openRepoSettings(orcaPage, repo.id)
const repoSection = orcaPage.locator(`[data-settings-section="repo-${repo.id}"]`)
const displayNameInput = repoSection.getByLabel('Display Name')
await expect(displayNameInput).toHaveValue(repo.displayName)
// Clear and wait for the async store echo to settle before composing.
await displayNameInput.click()
await displayNameInput.fill('')
await expect(displayNameInput).toHaveValue('')
const session = await orcaPage.context().newCDPSession(orcaPage)
await typeHangulGanadaSlowly(session, orcaPage, displayNameInput)
// Why: with the store-bound controlled input, the async updateRepo echo
// reset the field mid-composition, aborting the IME session per keystroke
// and committing bare jamo (ㄱㅏㄴㅏㄷㅏ) instead of syllables.
await expect(displayNameInput).toHaveValue('가나다')
// The per-keystroke persist still reaches the store.
await expect
.poll(
async () => {
const current = await getStoreState<Repo[]>(orcaPage, 'repos')
return current.find((entry) => entry.id === repo.id)?.displayName
},
{ timeout: 5_000, message: 'display name did not persist to the store' }
)
.toBe('가나다')
})
})