mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
test(ime): add a recorded-trace harness and one composition predicate
Synthetic keystrokes cannot produce a composition — KeyboardEvents dispatched through CDP bypass the OS input-method layer — so the IME suites each grew their own ad-hoc dispatch helpers that assert against invented event orderings. Adds a trace harness that replays an event sequence captured from a real engine, stamping the observed buffer before each event so the code under test sees what the browser actually produced. Traces carry a provenance field: `recorded` traces come from a real engine and are authoritative, `derived` traces pin current behavior but prove nothing about the engine, so a fix justified only by a derived trace is visibly unverified. Two of the four seeded traces are recorded from ibus-hangul in CI; the Telex and Pinyin ones are derived and say so. Also replaces 17 inline `nativeEvent.isComposing` guards with one predicate. `isComposing` alone misses the extra keydown Safari emits after compositionend and the engines that report only keyCode 229 or key 'Process', so each call site was subtly different. The shared predicate adds a document-wide composition flag cleared on focusout, because compositionend is not delivered when the composing element loses focus. It also tracks the composing target and revalidates that the target is still connected on read: Chromium fires no focusout when a focused node is removed, so a composer that unmounts mid-composition would otherwise latch the flag on for the session and kill every Enter-to-commit surface in the app. The three remaining TaskPage sites go through shouldSuppressEnterSubmit, which now delegates to the same predicate. An earlier version of this message called them telemetry-only; they are Enter-submit gates, so bare isComposing was missing an IME-owned Enter on Windows and Android. Trace assertions compare an interpretation derived from event data against each trace's declared final state. The previous assertion read back the buffer the harness itself had stamped, so it could not fail. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
hasBoundedCommentBodyText
|
||||
} from '@/lib/comment-body-submit-state'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
jiraAddIssueComment,
|
||||
@@ -609,7 +610,7 @@ export default function JiraIssueWorkspace({
|
||||
value={titleDraft}
|
||||
onChange={(event) => setTitleDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) {
|
||||
event.preventDefault()
|
||||
handleSaveTitle()
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context'
|
||||
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import {
|
||||
getSettingsFocusedExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
@@ -6482,12 +6483,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
(event: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
// React SyntheticEvent does not expose isComposing; use nativeEvent.
|
||||
if (
|
||||
shouldSuppressEnterSubmit(
|
||||
{ isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey },
|
||||
false
|
||||
)
|
||||
) {
|
||||
if (shouldSuppressEnterSubmit(event.nativeEvent, false)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -8622,15 +8618,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
onChange={(e) => setLinearSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (
|
||||
shouldSuppressEnterSubmit(
|
||||
{
|
||||
isComposing: e.nativeEvent.isComposing,
|
||||
shiftKey: e.shiftKey
|
||||
},
|
||||
false
|
||||
)
|
||||
) {
|
||||
if (shouldSuppressEnterSubmit(e.nativeEvent, false)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
@@ -8806,12 +8794,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
onChange={(e) => setJiraSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (
|
||||
shouldSuppressEnterSubmit(
|
||||
{ isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey },
|
||||
false
|
||||
)
|
||||
) {
|
||||
if (shouldSuppressEnterSubmit(e.nativeEvent, false)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
@@ -11034,7 +11017,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
value={newIssueTitle}
|
||||
onChange={(e) => setNewIssueTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
void handleCreateNewIssue()
|
||||
}
|
||||
@@ -11208,7 +11191,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
value={newLinearProjectName}
|
||||
onChange={(event) => setNewLinearProjectName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) {
|
||||
event.preventDefault()
|
||||
void handleCreateNewLinearProject()
|
||||
}
|
||||
@@ -11655,7 +11638,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
value={newLinearIssueTitle}
|
||||
onChange={(e) => setNewLinearIssueTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
void handleCreateNewLinearIssue()
|
||||
}
|
||||
@@ -12255,7 +12238,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
value={newJiraIssueTitle}
|
||||
onChange={(e) => setNewJiraIssueTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
void handleCreateNewJiraIssue()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { MarkupToolbar } from './MarkupToolbar'
|
||||
import type { MarkupBaseImage } from './markup-base-image'
|
||||
@@ -83,7 +84,7 @@ export function MarkupOverlay({
|
||||
event.stopPropagation()
|
||||
// Why: during IME composition (e.g. Japanese conversion), Enter
|
||||
// confirms the candidate — it must NOT also commit the annotation.
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) {
|
||||
event.preventDefault()
|
||||
editor.commitPendingText(event.currentTarget.value)
|
||||
} else if (event.key === 'Escape') {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CornerDownLeft, Pencil, Trash } from 'lucide-react'
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -292,7 +293,7 @@ export function DiffCommentCard({
|
||||
handleCancel()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !isImeCompositionKeyDown(e) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) {
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from
|
||||
import { CornerDownLeft } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import {
|
||||
getCommentBodySubmitState,
|
||||
@@ -214,7 +215,7 @@ export function DiffCommentPopover({
|
||||
return
|
||||
}
|
||||
// Why: Shift+Enter inserts a newline; skip isComposing so IME composition Enter doesn't submit a half-typed CJK note.
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !isImeCompositionKeyDown(e) && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (submitting) {
|
||||
return
|
||||
|
||||
@@ -41,6 +41,7 @@ import { getConnectionIdForFile } from '@/lib/connection-context'
|
||||
import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
fileUrlToAbsolutePath,
|
||||
@@ -2120,7 +2121,7 @@ function MarkdownAnnotationComposer({
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing && !event.shiftKey) {
|
||||
if (event.key === 'Enter' && !isImeCompositionKeyDown(event) && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import {
|
||||
buildServeSimKeyboardFramesForKey,
|
||||
type ServeSimKeyboardFrame
|
||||
@@ -70,7 +71,7 @@ export function useEmulatorScreenKeyboard({
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!canInteract ||
|
||||
event.nativeEvent.isComposing ||
|
||||
isImeCompositionKeyDown(event) ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import GitHubItemDialog, { type GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
|
||||
import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { launchWorkItemDirect } from '@/lib/launch-work-item-direct'
|
||||
import { useRepoSlugIndex } from '@/lib/repo-slug-index'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -1103,7 +1104,7 @@ function ProjectSearchInput({
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
if (isImeCompositionKeyDown(e)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, type Dispatch, type KeyboardEventHandler, type SetStateAction } from 'react'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import {
|
||||
recallNext,
|
||||
recallPrevious,
|
||||
@@ -42,7 +43,7 @@ export function useNativeChatComposerKeyDown({
|
||||
}: UseNativeChatComposerKeyDownArgs): KeyboardEventHandler<HTMLTextAreaElement> {
|
||||
return useCallback(
|
||||
(event) => {
|
||||
if (isComposing() || event.nativeEvent.isComposing || event.keyCode === 229) {
|
||||
if (isComposing() || isImeCompositionKeyDown(event)) {
|
||||
// Why: IME Enter confirms composition; allowing it to fall through
|
||||
// would accept a picker row or submit a partial draft.
|
||||
if (event.key === 'Enter') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Plus, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { Button } from '../ui/button'
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
|
||||
@@ -217,7 +218,7 @@ export function AddressPicker({
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.nativeEvent.isComposing
|
||||
isImeCompositionKeyDown(event)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type React from 'react'
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useActiveWorktree } from '@/store/selectors'
|
||||
import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types'
|
||||
@@ -206,7 +207,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
if (isImeCompositionKeyDown(e)) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Folder } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
|
||||
@@ -42,7 +43,7 @@ export function CloneStep({
|
||||
const canBrowseRemoteDestination = isRemoteClone
|
||||
const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) {
|
||||
e.preventDefault()
|
||||
if (canClone) {
|
||||
onClone()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CircleStop, FolderOpen, Settings } from 'lucide-react'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types'
|
||||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
import { SshTargetRow } from './SshTargetRow'
|
||||
@@ -172,7 +173,7 @@ export function RemoteStep({
|
||||
value={remotePath}
|
||||
onChange={(event) => onRemotePathChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) {
|
||||
event.preventDefault()
|
||||
if (selectedTargetId && remotePath.trim() && !isAddingRemote) {
|
||||
onAdd()
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const ANNOUNCE_DEBOUNCE_MS = 400
|
||||
@@ -119,7 +120,7 @@ export default function WorkspaceKanbanSearchField({
|
||||
// this field is the only handler — it must cover both outcomes.
|
||||
// Mid-composition Escape belongs to the IME, which cancels the
|
||||
// in-progress reading rather than the query behind it.
|
||||
if (event.key !== 'Escape' || event.nativeEvent.isComposing) {
|
||||
if (event.key !== 'Escape' || isImeCompositionKeyDown(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { isImeCompositionKeyDown } from './ime-composition-keyboard-event'
|
||||
import {
|
||||
_setDocumentImeCompositionActiveForTests,
|
||||
isDocumentImeCompositionActive,
|
||||
isImeCompositionKeyDown
|
||||
} from './ime-composition-keyboard-event'
|
||||
|
||||
function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent {
|
||||
function keyEvent(nativeEvent: {
|
||||
isComposing?: boolean
|
||||
key?: string
|
||||
keyCode?: number
|
||||
}): ReactKeyboardEvent {
|
||||
return {
|
||||
nativeEvent: {
|
||||
isComposing: nativeEvent.isComposing ?? false,
|
||||
key: nativeEvent.key ?? 'Enter',
|
||||
keyCode: nativeEvent.keyCode ?? 13
|
||||
}
|
||||
} as unknown as ReactKeyboardEvent
|
||||
}
|
||||
|
||||
function dispatchOnBody(type: string): void {
|
||||
document.body.dispatchEvent(new CompositionEvent(type, { bubbles: true, data: '' }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
_setDocumentImeCompositionActiveForTests(false)
|
||||
})
|
||||
|
||||
describe('isImeCompositionKeyDown', () => {
|
||||
it('is true while the IME is composing', () => {
|
||||
expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true)
|
||||
@@ -20,7 +38,89 @@ describe('isImeCompositionKeyDown', () => {
|
||||
expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for the Process key some engines report instead of 229', () => {
|
||||
expect(isImeCompositionKeyDown(keyEvent({ key: 'Process', keyCode: 0 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a plain Enter outside of composition', () => {
|
||||
expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ArrowDown', 40],
|
||||
['Backspace', 8],
|
||||
['Escape', 27],
|
||||
['a', 65]
|
||||
])('leaves a plain %s outside composition alone', (key, keyCode) => {
|
||||
expect(isImeCompositionKeyDown(keyEvent({ key, keyCode }))).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a native KeyboardEvent as well as a React synthetic event', () => {
|
||||
const native = new KeyboardEvent('keydown', { isComposing: true, key: 'Enter' })
|
||||
|
||||
expect(isImeCompositionKeyDown(native)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the document flag when handed no event', () => {
|
||||
_setDocumentImeCompositionActiveForTests(true)
|
||||
|
||||
expect(isImeCompositionKeyDown(null)).toBe(true)
|
||||
expect(isImeCompositionKeyDown(undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document composition tracking', () => {
|
||||
it('suppresses the extra keydown Safari emits after compositionend', () => {
|
||||
// compositionend has already cleared the flag by the time this arrives, so the
|
||||
// numeric 229 marker is the only bit left that can catch it.
|
||||
dispatchOnBody('compositionstart')
|
||||
dispatchOnBody('compositionend')
|
||||
expect(isDocumentImeCompositionActive()).toBe(false)
|
||||
|
||||
expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses a key that arrives mid-composition carrying no marker of its own', () => {
|
||||
dispatchOnBody('compositionstart')
|
||||
|
||||
expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks composition anywhere in the document', () => {
|
||||
dispatchOnBody('compositionstart')
|
||||
expect(isDocumentImeCompositionActive()).toBe(true)
|
||||
|
||||
dispatchOnBody('compositionend')
|
||||
expect(isDocumentImeCompositionActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the flag on focus loss because compositionend may never arrive', () => {
|
||||
dispatchOnBody('compositionstart')
|
||||
expect(isDocumentImeCompositionActive()).toBe(true)
|
||||
|
||||
document.body.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
|
||||
expect(isDocumentImeCompositionActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not leave Enter permanently suppressed after an abandoned composition', () => {
|
||||
dispatchOnBody('compositionstart')
|
||||
document.body.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
|
||||
expect(isImeCompositionKeyDown(keyEvent({ keyCode: 13 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the flag when the composing element unmounts without focusout', () => {
|
||||
// Chromium fires no focusout when a focused node is removed, so a composer
|
||||
// that unmounts mid-composition would otherwise latch this on for good.
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
|
||||
expect(isDocumentImeCompositionActive()).toBe(true)
|
||||
|
||||
input.remove()
|
||||
|
||||
expect(isDocumentImeCompositionActive()).toBe(false)
|
||||
expect(isImeCompositionKeyDown(keyEvent({ keyCode: 13 }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,104 @@
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
|
||||
// The one place that decides a keystroke belongs to an IME.
|
||||
// See the IME Composition rules in AGENTS.md before changing any of this.
|
||||
|
||||
type ImeCandidateKeyboardEvent = Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>
|
||||
|
||||
type AnyKeyboardEvent =
|
||||
| ImeCandidateKeyboardEvent
|
||||
| Pick<ReactKeyboardEvent, 'nativeEvent'>
|
||||
| null
|
||||
| undefined
|
||||
|
||||
function toNativeKeyboardEvent(event: AnyKeyboardEvent): ImeCandidateKeyboardEvent | null {
|
||||
if (!event) {
|
||||
return null
|
||||
}
|
||||
if ('nativeEvent' in event) {
|
||||
return (event.nativeEvent as ImeCandidateKeyboardEvent | undefined) ?? null
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
// Why: browsers do not reliably deliver compositionend when the composing element
|
||||
// loses focus or leaves the DOM, so the target is kept rather than a bare boolean. Focus loss clears it; unmounting cannot be observed
|
||||
// that way, because Chromium fires no focusout when a focused node is removed —
|
||||
// so a live read also revalidates that the target is still connected. Without
|
||||
// that, one unmount mid-composition would latch this flag on for the session and
|
||||
// silently kill every Enter-to-commit surface in the app.
|
||||
let compositionTarget: Node | null = null
|
||||
let compositionActiveWithoutTarget = false
|
||||
|
||||
function setCompositionTarget(target: Node | null): void {
|
||||
compositionTarget = target
|
||||
compositionActiveWithoutTarget = false
|
||||
}
|
||||
|
||||
function installDocumentCompositionTracking(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
document.addEventListener(
|
||||
'compositionstart',
|
||||
(event) => setCompositionTarget(event.target as Node | null),
|
||||
true
|
||||
)
|
||||
document.addEventListener('compositionend', () => setCompositionTarget(null), true)
|
||||
document.addEventListener('focusout', () => setCompositionTarget(null), true)
|
||||
}
|
||||
|
||||
installDocumentCompositionTracking()
|
||||
|
||||
/** True while any element in the document is mid-composition. */
|
||||
export function isDocumentImeCompositionActive(): boolean {
|
||||
if (compositionActiveWithoutTarget) {
|
||||
return true
|
||||
}
|
||||
if (!compositionTarget) {
|
||||
return false
|
||||
}
|
||||
if (!compositionTarget.isConnected) {
|
||||
compositionTarget = null
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function _setDocumentImeCompositionActiveForTests(active: boolean): void {
|
||||
compositionTarget = null
|
||||
compositionActiveWithoutTarget = active
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that
|
||||
* only confirms a conversion candidate. Rename/title inputs that commit on
|
||||
* `Enter` must ignore that keydown, otherwise they submit mid-composition with a
|
||||
* half-converted value. `isComposing` covers most browsers; `keyCode === 229` is
|
||||
* a defensive fallback for IMEs that don't set `isComposing` on keydown.
|
||||
* True when an IME owns this keystroke, so the app must not act on it.
|
||||
*
|
||||
* `keyCode === 229` and `key === 'Process'` are the Windows and Android markers for
|
||||
* a key the IME consumed. 229 is also what catches Safari's extra keydown *after*
|
||||
* compositionend, where the app-wide flag cannot help because compositionend has
|
||||
* already cleared it — the numeric marker is the only bit still set.
|
||||
*
|
||||
* The flag covers the opposite gap: keys that arrive *during* a live composition
|
||||
* carrying neither marker, which is what the per-event bits miss.
|
||||
*
|
||||
* Call this above the key dispatch, not inside each key branch, so every action
|
||||
* (send, history navigation, completion, cancel) is suppressed together.
|
||||
*
|
||||
* The 229 / 'Process' markers are unconditional here — there is deliberately no
|
||||
* opt-out argument. The only surface that needs a marked key to pass through is
|
||||
* the terminal, so it can reach xterm's CompositionHelper, and that decision
|
||||
* lives in xterm-bypass-policy.ts instead. Every caller of this predicate is
|
||||
* ordinary UI, so an opt-out would only ever be dead API.
|
||||
*/
|
||||
export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean {
|
||||
const nativeEvent = event.nativeEvent
|
||||
return nativeEvent.isComposing || nativeEvent.keyCode === 229
|
||||
export function isImeCompositionKeyDown(event: AnyKeyboardEvent): boolean {
|
||||
const nativeEvent = toNativeKeyboardEvent(event)
|
||||
if (!nativeEvent) {
|
||||
return isDocumentImeCompositionActive()
|
||||
}
|
||||
return (
|
||||
isDocumentImeCompositionActive() ||
|
||||
nativeEvent.isComposing === true ||
|
||||
nativeEvent.keyCode === 229 ||
|
||||
nativeEvent.key === 'Process'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// Recorded-trace harness for IME tests. See the IME rules in AGENTS.md.
|
||||
//
|
||||
// Synthetic keystrokes cannot produce a real composition: KeyboardEvents dispatched
|
||||
// through CDP bypass the OS input-method layer entirely. The only faithful way to
|
||||
// test IME behavior is to replay an event sequence captured from a real engine,
|
||||
// stamping the observed target state before each event so the code under test sees
|
||||
// byte-for-byte what the browser produced.
|
||||
|
||||
export type ImeTracePlatform = 'darwin' | 'linux' | 'win32'
|
||||
|
||||
export type ImeTraceEnvironment = {
|
||||
platform: ImeTracePlatform
|
||||
browser: 'chromium' | 'gecko' | 'webkit'
|
||||
/** Engine name for humans only. No production code may branch on it. */
|
||||
engine: string
|
||||
}
|
||||
|
||||
/** The input element's observed state at the instant an event fired. */
|
||||
export type ImeTraceTargetState = {
|
||||
value: string
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
}
|
||||
|
||||
type ImeTraceEventBase = { state: ImeTraceTargetState }
|
||||
|
||||
export type ImeTraceKeyEvent = ImeTraceEventBase & {
|
||||
type: 'keydown' | 'keypress' | 'keyup'
|
||||
key: string
|
||||
code: string
|
||||
keyCode: number
|
||||
isComposing: boolean
|
||||
altKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
metaKey?: boolean
|
||||
shiftKey?: boolean
|
||||
}
|
||||
|
||||
export type ImeTraceCompositionEvent = ImeTraceEventBase & {
|
||||
type: 'compositionstart' | 'compositionupdate' | 'compositionend'
|
||||
data: string
|
||||
}
|
||||
|
||||
export type ImeTraceInputEvent = ImeTraceEventBase & {
|
||||
type: 'beforeinput' | 'input'
|
||||
inputType: string
|
||||
data: string | null
|
||||
/** Safari reports `undefined` rather than `false`; the distinction is load-bearing. */
|
||||
isComposing: boolean | undefined
|
||||
}
|
||||
|
||||
export type ImeTraceEvent = ImeTraceCompositionEvent | ImeTraceInputEvent | ImeTraceKeyEvent
|
||||
|
||||
/**
|
||||
* `recorded` traces were captured from a real engine and are authoritative.
|
||||
* `derived` traces were reconstructed from a documented event ordering; they pin
|
||||
* current behavior but cannot prove the ordering is what the engine really emits.
|
||||
* A fix justified only by a `derived` trace is unverified — say so in the PR.
|
||||
*/
|
||||
export type ImeTraceProvenance = 'derived' | 'recorded'
|
||||
|
||||
export type ImeCompositionTrace = {
|
||||
name: string
|
||||
provenance: ImeTraceProvenance
|
||||
/** What the human did, where the trace came from, and the issue it belongs to. */
|
||||
origin: string
|
||||
env: ImeTraceEnvironment
|
||||
initial: ImeTraceTargetState
|
||||
events: ImeTraceEvent[]
|
||||
final: ImeTraceTargetState
|
||||
/** Text the surface under test must ultimately commit. Empty means "commit nothing". */
|
||||
committed: string
|
||||
}
|
||||
|
||||
export const EMPTY_IME_TRACE_STATE: ImeTraceTargetState = {
|
||||
value: '',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0
|
||||
}
|
||||
|
||||
function applyLegacyKeyCode(event: KeyboardEvent, keyCode: number): void {
|
||||
if (event.keyCode === keyCode) {
|
||||
return
|
||||
}
|
||||
// happy-dom ignores the legacy keyCode init member.
|
||||
Object.defineProperty(event, 'keyCode', { configurable: true, value: keyCode })
|
||||
Object.defineProperty(event, 'which', { configurable: true, value: keyCode })
|
||||
}
|
||||
|
||||
function applyIsComposing(event: Event, isComposing: boolean | undefined): void {
|
||||
if (
|
||||
isComposing === undefined ||
|
||||
(event as { isComposing?: boolean }).isComposing === isComposing
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Testing libraries cannot set isComposing any other way.
|
||||
Object.defineProperty(event, 'isComposing', { configurable: true, value: isComposing })
|
||||
}
|
||||
|
||||
export function createImeTraceKeyboardEvent(event: ImeTraceKeyEvent): KeyboardEvent {
|
||||
const created = new KeyboardEvent(event.type, {
|
||||
altKey: event.altKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: event.code,
|
||||
ctrlKey: event.ctrlKey ?? false,
|
||||
isComposing: event.isComposing,
|
||||
key: event.key,
|
||||
keyCode: event.keyCode,
|
||||
metaKey: event.metaKey ?? false,
|
||||
shiftKey: event.shiftKey ?? false
|
||||
})
|
||||
applyLegacyKeyCode(created, event.keyCode)
|
||||
applyIsComposing(created, event.isComposing)
|
||||
return created
|
||||
}
|
||||
|
||||
export function createImeTraceCompositionEvent(event: ImeTraceCompositionEvent): CompositionEvent {
|
||||
return new CompositionEvent(event.type, { bubbles: true, cancelable: true, data: event.data })
|
||||
}
|
||||
|
||||
export function createImeTraceInputEvent(event: ImeTraceInputEvent): InputEvent {
|
||||
const created = new InputEvent(event.type, {
|
||||
bubbles: true,
|
||||
cancelable: event.type === 'beforeinput',
|
||||
composed: true,
|
||||
data: event.data,
|
||||
inputType: event.inputType,
|
||||
isComposing: event.isComposing ?? false
|
||||
})
|
||||
applyIsComposing(created, event.isComposing)
|
||||
return created
|
||||
}
|
||||
|
||||
export function createImeTraceEvent(event: ImeTraceEvent): Event {
|
||||
switch (event.type) {
|
||||
case 'beforeinput':
|
||||
case 'input':
|
||||
return createImeTraceInputEvent(event)
|
||||
case 'keydown':
|
||||
case 'keypress':
|
||||
case 'keyup':
|
||||
return createImeTraceKeyboardEvent(event)
|
||||
case 'compositionstart':
|
||||
case 'compositionupdate':
|
||||
case 'compositionend':
|
||||
return createImeTraceCompositionEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
export type ImeTraceReplayViolation = {
|
||||
eventType: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type ImeTraceReplay = {
|
||||
/** Invariant breaches observed during replay. A correct implementation leaves this empty. */
|
||||
violations: ImeTraceReplayViolation[]
|
||||
/** Every event dispatched, in order, for ordering assertions. */
|
||||
dispatched: Event[]
|
||||
}
|
||||
|
||||
export type ImeTraceReplayOptions = {
|
||||
/** Runs after each event so tests can observe intermediate state. */
|
||||
onEvent?: (event: Event, index: number) => void
|
||||
/** Yields between events to preserve async ordering. Defaults to a microtask. */
|
||||
yieldBetweenEvents?: () => Promise<void>
|
||||
}
|
||||
|
||||
function readTargetState(target: HTMLTextAreaElement | HTMLInputElement): ImeTraceTargetState {
|
||||
return {
|
||||
selectionEnd: target.selectionEnd ?? 0,
|
||||
selectionStart: target.selectionStart ?? 0,
|
||||
value: target.value
|
||||
}
|
||||
}
|
||||
|
||||
function stampTargetState(
|
||||
target: HTMLTextAreaElement | HTMLInputElement,
|
||||
state: ImeTraceTargetState
|
||||
): void {
|
||||
target.value = state.value
|
||||
target.setSelectionRange(state.selectionStart, state.selectionEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a recorded trace against a real input element.
|
||||
*
|
||||
* The recorded state is stamped in *before* each event fires, so the code under test
|
||||
* sees the browser's actual buffer rather than a simulation of it.
|
||||
*/
|
||||
export async function replayImeCompositionTrace(
|
||||
target: HTMLTextAreaElement | HTMLInputElement,
|
||||
trace: ImeCompositionTrace,
|
||||
options: ImeTraceReplayOptions = {}
|
||||
): Promise<ImeTraceReplay> {
|
||||
const violations: ImeTraceReplayViolation[] = []
|
||||
const dispatched: Event[] = []
|
||||
const yieldBetweenEvents = options.yieldBetweenEvents ?? (() => Promise.resolve())
|
||||
|
||||
stampTargetState(target, trace.initial)
|
||||
|
||||
for (const [index, event] of trace.events.entries()) {
|
||||
stampTargetState(target, event.state)
|
||||
const stamped = readTargetState(target)
|
||||
const created = createImeTraceEvent(event)
|
||||
|
||||
target.dispatchEvent(created)
|
||||
dispatched.push(created)
|
||||
|
||||
const after = readTargetState(target)
|
||||
if (event.type === 'compositionstart' && after.value !== stamped.value) {
|
||||
// Clearing the textarea here makes the browser skip compositionend entirely.
|
||||
violations.push({
|
||||
eventType: event.type,
|
||||
reason: `wrote ${JSON.stringify(after.value)} to the target during compositionstart`
|
||||
})
|
||||
}
|
||||
|
||||
options.onEvent?.(created, index)
|
||||
await yieldBetweenEvents()
|
||||
}
|
||||
|
||||
return { dispatched, violations }
|
||||
}
|
||||
|
||||
export function createImeTraceTextarea(document: Document): HTMLTextAreaElement {
|
||||
const textarea = document.createElement('textarea')
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
return textarea
|
||||
}
|
||||
|
||||
/** One committed chunk, positioned by range rather than by diffing the buffer. */
|
||||
export type ImeCommit = {
|
||||
text: string
|
||||
/** Preedit characters immediately before the caret that this commit replaces. */
|
||||
replacePrevCharCnt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Splices commits into the trace's initial text at the caret and returns the resulting
|
||||
* state. Commits replace rather than only append: Hangul input commits each jamo and
|
||||
* then re-commits the composed syllable over it, so most events carry
|
||||
* `replacePrevCharCnt: 1`, and an implementation that appended those instead would
|
||||
* produce the right characters in the wrong order.
|
||||
*/
|
||||
export function interpretImeCommits(
|
||||
initial: ImeTraceTargetState,
|
||||
commits: readonly ImeCommit[]
|
||||
): ImeTraceTargetState {
|
||||
let value = initial.value
|
||||
let caret = initial.selectionStart
|
||||
|
||||
for (const commit of commits) {
|
||||
const replaceFrom = Math.max(0, caret - (commit.replacePrevCharCnt ?? 0))
|
||||
value = value.slice(0, replaceFrom) + commit.text + value.slice(caret)
|
||||
caret = replaceFrom + commit.text.length
|
||||
}
|
||||
|
||||
return { selectionEnd: caret, selectionStart: caret, value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the commits a correct surface must derive from this trace, using only event
|
||||
* `data` — never a diff of two buffer observations, per the rule in AGENTS.md.
|
||||
*
|
||||
* Two shapes deliver a commit. Most engines follow `compositionend` with an
|
||||
* `insertText` carrying the committed text. ibus-hangul often does not: it leaves the
|
||||
* text in the buffer from the last `insertCompositionText` and `compositionend` is the
|
||||
* only carrier. Taking both without this de-duplication would double every commit.
|
||||
*/
|
||||
export function extractImeCommitsFromTrace(trace: ImeCompositionTrace): ImeCommit[] {
|
||||
const commits: ImeCommit[] = []
|
||||
|
||||
for (const [index, event] of trace.events.entries()) {
|
||||
if (event.type === 'input' && event.inputType === 'insertText' && event.data !== null) {
|
||||
commits.push({ text: event.data })
|
||||
continue
|
||||
}
|
||||
if (event.type !== 'compositionend' || event.data === '') {
|
||||
continue
|
||||
}
|
||||
// Only the window before the next composition or plain keystroke counts: a later
|
||||
// insertText from ordinary typing must not cancel a retained commit.
|
||||
const followUp = trace.events
|
||||
.slice(index + 1)
|
||||
.find((later) => later.type === 'input' || later.type === 'keydown')
|
||||
const deliveredByInsertText = followUp?.type === 'input' && followUp.inputType === 'insertText'
|
||||
if (!deliveredByInsertText) {
|
||||
commits.push({ text: event.data })
|
||||
}
|
||||
}
|
||||
|
||||
return commits
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createImeTraceTextarea,
|
||||
extractImeCommitsFromTrace,
|
||||
interpretImeCommits,
|
||||
replayImeCompositionTrace
|
||||
} from './ime-composition-trace.test-fixtures'
|
||||
import type { ImeTraceKeyEvent } from './ime-composition-trace.test-fixtures'
|
||||
import { isImeCompositionKeyDown } from './ime-composition-keyboard-event'
|
||||
import {
|
||||
IBUS_HANGUL_MIXED_LATIN_TRACE,
|
||||
IBUS_HANGUL_RETAINED_COMMIT_TRACE,
|
||||
IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE,
|
||||
IME_COMPOSITION_TRACES
|
||||
} from './ime-recorded-composition-traces.test-fixtures'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('recorded composition traces', () => {
|
||||
it.each(IME_COMPOSITION_TRACES.map((trace) => [trace.name, trace] as const))(
|
||||
'%s replays to its recorded final state',
|
||||
async (_name, trace) => {
|
||||
const textarea = createImeTraceTextarea(document)
|
||||
|
||||
const replay = await replayImeCompositionTrace(textarea, trace)
|
||||
|
||||
expect(replay.violations).toEqual([])
|
||||
expect(replay.dispatched).toHaveLength(trace.events.length)
|
||||
// Not asserted here: the textarea's final contents. The harness stamps each
|
||||
// event's recorded state in before dispatching it, so after the last event the
|
||||
// textarea necessarily holds `trace.final` no matter what any listener did.
|
||||
// The real check is the independent interpretation below.
|
||||
}
|
||||
)
|
||||
|
||||
it.each(IME_COMPOSITION_TRACES.map((trace) => [trace.name, trace] as const))(
|
||||
'%s reaches its final text from event data alone',
|
||||
(_name, trace) => {
|
||||
// Derived from the event stream, never from the stamped buffer, so this fails
|
||||
// when a trace's declared data does not actually add up to what it claims.
|
||||
const commits = extractImeCommitsFromTrace(trace)
|
||||
|
||||
expect(commits.map((commit) => commit.text).join('')).toBe(trace.committed)
|
||||
expect(interpretImeCommits(trace.initial, commits)).toEqual(trace.final)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports the wrong text when commits are appended instead of replaced', () => {
|
||||
// Guards the interpreter itself: Hangul commits carry replacePrevCharCnt: 1 on
|
||||
// most events, and an appending implementation would score as correct without this.
|
||||
const initial = { selectionEnd: 0, selectionStart: 0, value: '' }
|
||||
const replacing = [{ text: 'ㅇ' }, { replacePrevCharCnt: 1, text: '아' }]
|
||||
|
||||
expect(interpretImeCommits(initial, replacing).value).toBe('아')
|
||||
expect(interpretImeCommits(initial, [{ text: 'ㅇ' }, { text: '아' }]).value).toBe('ㅇ아')
|
||||
})
|
||||
|
||||
it('classifies the recorded Enter that ibus-hangul consumed to commit', () => {
|
||||
// Ground truth for what a real engine actually sends, which the derived traces were
|
||||
// only guessing at: ibus-hangul sets all three markers on the committing Enter, so
|
||||
// any one of them would catch it here. The union still earns its keep — the Windows
|
||||
// and Safari cases in ime-composition-keyboard-event.test.ts carry only some of them.
|
||||
const committingEnter = IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.events.find(
|
||||
(event) => event.type === 'keydown' && event.code === 'Enter'
|
||||
)
|
||||
|
||||
expect(committingEnter).toMatchObject({ isComposing: true, key: 'Process', keyCode: 229 })
|
||||
expect(isImeCompositionKeyDown(committingEnter as ImeTraceKeyEvent)).toBe(true)
|
||||
// The paired negative: an otherwise identical Enter with no IME marker must submit.
|
||||
expect(isImeCompositionKeyDown({ isComposing: false, key: 'Enter', keyCode: 13 })).toBe(false)
|
||||
})
|
||||
|
||||
it('derives no commit from the plain ASCII typed between two compositions', () => {
|
||||
// 'abc' goes straight to the PTY in the recorded trace and never becomes a commit.
|
||||
const commits = extractImeCommitsFromTrace(IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE)
|
||||
|
||||
expect(commits.map((commit) => commit.text)).toEqual(['한', '글'])
|
||||
})
|
||||
|
||||
it('survives the deleteContentBackward that ibus-hangul emits before committing', () => {
|
||||
// A buffer-diffing implementation loses the character here: the engine tears the
|
||||
// preedit down first, so the buffer is shorter immediately before the commit.
|
||||
const events = IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.events
|
||||
const teardown = events.findIndex(
|
||||
(event) => event.type === 'input' && event.inputType === 'deleteContentBackward'
|
||||
)
|
||||
|
||||
expect(teardown).toBeGreaterThan(-1)
|
||||
expect(events[teardown + 1]?.type).toBe('compositionend')
|
||||
expect(
|
||||
interpretImeCommits(
|
||||
IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.initial,
|
||||
extractImeCommitsFromTrace(IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE)
|
||||
)
|
||||
).toEqual(IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.final)
|
||||
})
|
||||
|
||||
it('keeps a retained commit that ordinary typing follows', () => {
|
||||
// The de-dup must not let a later insertText from plain typing cancel a
|
||||
// compositionend that was the only carrier of its commit.
|
||||
const retained = IME_COMPOSITION_TRACES.find((trace) => trace.name.includes('retained commit'))
|
||||
|
||||
expect(extractImeCommitsFromTrace(retained!).map((c) => c.text)).toEqual(['테', '스'])
|
||||
})
|
||||
|
||||
it.each(IME_COMPOSITION_TRACES.map((trace) => [trace.name, trace] as const))(
|
||||
'%s declares a non-empty origin and a provenance',
|
||||
(_name, trace) => {
|
||||
expect(trace.origin.length).toBeGreaterThan(0)
|
||||
expect(['derived', 'recorded']).toContain(trace.provenance)
|
||||
}
|
||||
)
|
||||
|
||||
it('marks every event with the buffer state observed at that instant', () => {
|
||||
for (const trace of IME_COMPOSITION_TRACES) {
|
||||
for (const event of trace.events) {
|
||||
expect(event.state.selectionStart).toBeLessThanOrEqual(event.state.value.length)
|
||||
expect(event.state.selectionEnd).toBeLessThanOrEqual(event.state.value.length)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('replayImeCompositionTrace', () => {
|
||||
it('stamps the recorded buffer before each event rather than simulating it', async () => {
|
||||
const textarea = createImeTraceTextarea(document)
|
||||
const seen: string[] = []
|
||||
textarea.addEventListener('compositionupdate', () => seen.push(textarea.value))
|
||||
|
||||
await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE)
|
||||
|
||||
// The engine reports the pre-edit buffer on compositionupdate, not the composed text.
|
||||
expect(seen).toEqual(['', '테'])
|
||||
})
|
||||
|
||||
it('reports a listener that writes to the buffer during compositionstart', async () => {
|
||||
const textarea = createImeTraceTextarea(document)
|
||||
textarea.addEventListener('compositionstart', () => {
|
||||
// Clearing here makes real browsers skip compositionend entirely.
|
||||
textarea.value = ''
|
||||
})
|
||||
|
||||
const replay = await replayImeCompositionTrace(textarea, IBUS_HANGUL_MIXED_LATIN_TRACE)
|
||||
|
||||
expect(replay.violations).toEqual([
|
||||
{ eventType: 'compositionstart', reason: 'wrote "" to the target during compositionstart' }
|
||||
])
|
||||
})
|
||||
|
||||
it('yields between events so async handlers observe the recorded ordering', async () => {
|
||||
const textarea = createImeTraceTextarea(document)
|
||||
const order: string[] = []
|
||||
|
||||
await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE, {
|
||||
onEvent: (event) => order.push(event.type),
|
||||
yieldBetweenEvents: () => Promise.resolve()
|
||||
})
|
||||
|
||||
expect(order.slice(0, 4)).toEqual([
|
||||
'compositionstart',
|
||||
'keydown',
|
||||
'compositionupdate',
|
||||
'beforeinput'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the legacy keyCode engines report for IME-consumed keys', async () => {
|
||||
const textarea = createImeTraceTextarea(document)
|
||||
const processKeyCodes: number[] = []
|
||||
textarea.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Process') {
|
||||
processKeyCodes.push(event.keyCode)
|
||||
}
|
||||
})
|
||||
|
||||
await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE)
|
||||
|
||||
expect(processKeyCodes).toEqual([229, 229])
|
||||
})
|
||||
|
||||
it('preserves isComposing on the keystrokes the IME owns', async () => {
|
||||
const textarea = createImeTraceTextarea(document)
|
||||
const composing: boolean[] = []
|
||||
textarea.addEventListener('keydown', (event) => composing.push(event.isComposing))
|
||||
|
||||
await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE)
|
||||
|
||||
// Process keys are composing; the interleaved 'a' and the trailing Enter are not.
|
||||
expect(composing).toEqual([true, false, true, false])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
// Composition traces used across the IME suites. See the IME rules in AGENTS.md.
|
||||
//
|
||||
// Adding a trace: prefer capturing one from a real engine and mark it `recorded`.
|
||||
// A `derived` trace pins current behavior but proves nothing about the engine.
|
||||
|
||||
import type {
|
||||
ImeCompositionTrace,
|
||||
ImeTraceEvent,
|
||||
ImeTraceTargetState
|
||||
} from './ime-composition-trace.test-fixtures'
|
||||
import { IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE } from './ime-recorded-ibus-hangul-trace.test-fixtures'
|
||||
|
||||
export { IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE }
|
||||
|
||||
/** Caret collapsed at the end of `value`, which is where every trace below sits. */
|
||||
function caretAtEnd(value: string): ImeTraceTargetState {
|
||||
return { selectionEnd: value.length, selectionStart: value.length, value }
|
||||
}
|
||||
|
||||
function compositionStart(value: string): ImeTraceEvent {
|
||||
return { data: '', state: caretAtEnd(value), type: 'compositionstart' }
|
||||
}
|
||||
|
||||
function compositionUpdate(value: string, data: string): ImeTraceEvent {
|
||||
return { data, state: caretAtEnd(value), type: 'compositionupdate' }
|
||||
}
|
||||
|
||||
function compositionEnd(value: string, data: string): ImeTraceEvent {
|
||||
return { data, state: caretAtEnd(value), type: 'compositionend' }
|
||||
}
|
||||
|
||||
function processKeydown(value: string, code: string): ImeTraceEvent {
|
||||
return {
|
||||
code,
|
||||
isComposing: true,
|
||||
key: 'Process',
|
||||
keyCode: 229,
|
||||
state: caretAtEnd(value),
|
||||
type: 'keydown'
|
||||
}
|
||||
}
|
||||
|
||||
function plainKeydown(value: string, key: string, code: string, keyCode: number): ImeTraceEvent {
|
||||
return { code, isComposing: false, key, keyCode, state: caretAtEnd(value), type: 'keydown' }
|
||||
}
|
||||
|
||||
/** `beforeinput` carries the pre-edit buffer; `input` carries the post-edit buffer. */
|
||||
function editPair(before: string, after: string, inputType: string, data: string): ImeTraceEvent[] {
|
||||
const isComposing = inputType === 'insertCompositionText'
|
||||
return [
|
||||
{ data, inputType, isComposing, state: caretAtEnd(before), type: 'beforeinput' },
|
||||
{ data, inputType, isComposing, state: caretAtEnd(after), type: 'input' }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hangul syllables committed without a trailing `insertText` — the composed text is
|
||||
* retained in the buffer from the last `insertCompositionText`. A surface that waits
|
||||
* for `insertText` before committing drops both syllables.
|
||||
*/
|
||||
export const IBUS_HANGUL_RETAINED_COMMIT_TRACE: ImeCompositionTrace = {
|
||||
committed: '테스',
|
||||
env: { browser: 'chromium', engine: 'ibus-hangul', platform: 'linux' },
|
||||
events: [
|
||||
compositionStart(''),
|
||||
processKeydown('', 'KeyG'),
|
||||
compositionUpdate('', '테'),
|
||||
...editPair('', '테', 'insertCompositionText', '테'),
|
||||
compositionEnd('테', '테'),
|
||||
plainKeydown('테', 'a', 'KeyA', 65),
|
||||
processKeydown('테', 'KeyR'),
|
||||
compositionStart('테'),
|
||||
compositionUpdate('테', '스'),
|
||||
...editPair('테', '테스', 'insertCompositionText', '스'),
|
||||
compositionEnd('테스', '스'),
|
||||
plainKeydown('테스', 'Enter', 'Enter', 13)
|
||||
],
|
||||
final: caretAtEnd('테스'),
|
||||
initial: caretAtEnd(''),
|
||||
name: 'Linux - Chromium - ibus-hangul - retained commit',
|
||||
origin:
|
||||
'Transcribed from the synthetic sequence terminal-ime-e2e replays in CI ' +
|
||||
'(tests/e2e/terminal-ime-observed-event-sequences.ts), which hand-dispatches these ' +
|
||||
'events rather than capturing them. Selection offsets are reconstructed as a collapsed ' +
|
||||
'caret at end-of-buffer. To promote this to `recorded`, capture it through ' +
|
||||
'tests/e2e/terminal-ime-boundary-probe.ts, which reads true offsets off a live ' +
|
||||
'ibus-hangul session.',
|
||||
provenance: 'derived'
|
||||
}
|
||||
|
||||
/**
|
||||
* Hangul, then Latin typed directly, then Hangul again. Pins that a surface which
|
||||
* defers on composition does not also defer the interleaved plain keystrokes.
|
||||
*/
|
||||
export const IBUS_HANGUL_MIXED_LATIN_TRACE: ImeCompositionTrace = {
|
||||
committed: '한abc글',
|
||||
env: { browser: 'chromium', engine: 'ibus-hangul', platform: 'linux' },
|
||||
events: [
|
||||
compositionStart(''),
|
||||
processKeydown('', 'KeyG'),
|
||||
compositionUpdate('', '한'),
|
||||
...editPair('', '한', 'insertCompositionText', '한'),
|
||||
compositionUpdate('한', ''),
|
||||
...editPair('한', '', 'deleteContentBackward', ''),
|
||||
compositionEnd('', ''),
|
||||
...editPair('', '한', 'insertText', '한'),
|
||||
plainKeydown('한', 'a', 'KeyA', 65),
|
||||
...editPair('한', '한a', 'insertText', 'a'),
|
||||
plainKeydown('한a', 'b', 'KeyB', 66),
|
||||
...editPair('한a', '한ab', 'insertText', 'b'),
|
||||
plainKeydown('한ab', 'c', 'KeyC', 67),
|
||||
...editPair('한ab', '한abc', 'insertText', 'c'),
|
||||
compositionStart('한abc'),
|
||||
processKeydown('한abc', 'KeyG'),
|
||||
compositionUpdate('한abc', '글'),
|
||||
...editPair('한abc', '한abc글', 'insertCompositionText', '글'),
|
||||
compositionUpdate('한abc글', ''),
|
||||
...editPair('한abc글', '한abc', 'deleteContentBackward', ''),
|
||||
compositionEnd('한abc', ''),
|
||||
...editPair('한abc', '한abc글', 'insertText', '글'),
|
||||
plainKeydown('한abc글', 'Enter', 'Enter', 13)
|
||||
],
|
||||
final: caretAtEnd('한abc글'),
|
||||
initial: caretAtEnd(''),
|
||||
name: 'Linux - Chromium - ibus-hangul - mixed with Latin',
|
||||
origin:
|
||||
'Transcribed from the synthetic sequence terminal-ime-e2e replays in CI ' +
|
||||
'(tests/e2e/terminal-ime-observed-event-sequences.ts), which hand-dispatches these ' +
|
||||
'events rather than capturing them. Note the commit arrives as a deleteContentBackward ' +
|
||||
'that empties the preedit followed by insertText — a length-only diff sees the delete ' +
|
||||
'and sends a backspace. Promote via tests/e2e/terminal-ime-boundary-probe.ts.',
|
||||
provenance: 'derived'
|
||||
}
|
||||
|
||||
/**
|
||||
* Vietnamese Telex: `a` `a` composes `â`, then Backspace decomposes it back to `a`.
|
||||
* Backspace is IME-owned here — forwarding it to the PTY deletes real shell input.
|
||||
*/
|
||||
export const TELEX_BACKSPACE_MID_COMPOSITION_TRACE: ImeCompositionTrace = {
|
||||
committed: 'a',
|
||||
env: { browser: 'chromium', engine: 'Telex', platform: 'darwin' },
|
||||
events: [
|
||||
compositionStart(''),
|
||||
processKeydown('', 'KeyA'),
|
||||
compositionUpdate('', 'a'),
|
||||
...editPair('', 'a', 'insertCompositionText', 'a'),
|
||||
processKeydown('a', 'KeyA'),
|
||||
compositionUpdate('a', 'â'),
|
||||
...editPair('a', 'â', 'insertCompositionText', 'â'),
|
||||
{
|
||||
code: 'Backspace',
|
||||
isComposing: true,
|
||||
key: 'Backspace',
|
||||
keyCode: 8,
|
||||
state: caretAtEnd('â'),
|
||||
type: 'keydown'
|
||||
},
|
||||
compositionUpdate('â', 'a'),
|
||||
...editPair('â', 'a', 'insertCompositionText', 'a'),
|
||||
compositionEnd('a', 'a')
|
||||
],
|
||||
final: caretAtEnd('a'),
|
||||
initial: caretAtEnd(''),
|
||||
name: 'macOS - Chromium - Telex - Backspace decomposes mid-composition',
|
||||
origin:
|
||||
'Derived from the symptom in orca#6494 / #6698 / #6905: Backspace during a Telex ' +
|
||||
'composition reaches the PTY instead of the IME. Not hardware-recorded — the ordering ' +
|
||||
'follows the documented compositionupdate-per-keystroke contract.',
|
||||
provenance: 'derived'
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow keys select a candidate; they must not reach the shell's readline.
|
||||
*/
|
||||
export const CANDIDATE_ARROW_NAVIGATION_TRACE: ImeCompositionTrace = {
|
||||
committed: '中',
|
||||
env: { browser: 'chromium', engine: 'Pinyin', platform: 'darwin' },
|
||||
events: [
|
||||
compositionStart(''),
|
||||
processKeydown('', 'KeyZ'),
|
||||
compositionUpdate('', 'zhong'),
|
||||
...editPair('', 'zhong', 'insertCompositionText', 'zhong'),
|
||||
{
|
||||
code: 'ArrowDown',
|
||||
isComposing: true,
|
||||
key: 'ArrowDown',
|
||||
keyCode: 40,
|
||||
state: caretAtEnd('zhong'),
|
||||
type: 'keydown'
|
||||
},
|
||||
{
|
||||
code: 'ArrowUp',
|
||||
isComposing: true,
|
||||
key: 'ArrowUp',
|
||||
keyCode: 38,
|
||||
state: caretAtEnd('zhong'),
|
||||
type: 'keydown'
|
||||
},
|
||||
compositionUpdate('zhong', '中'),
|
||||
...editPair('zhong', '中', 'insertCompositionText', '中'),
|
||||
compositionEnd('中', '中')
|
||||
],
|
||||
final: caretAtEnd('中'),
|
||||
initial: caretAtEnd(''),
|
||||
name: 'macOS - Chromium - Pinyin - arrow candidate navigation',
|
||||
origin:
|
||||
'Derived from orca#9803: arrow keys during composition move the shell cursor. ' +
|
||||
'Not hardware-recorded.',
|
||||
provenance: 'derived'
|
||||
}
|
||||
|
||||
export const IME_COMPOSITION_TRACES: readonly ImeCompositionTrace[] = [
|
||||
IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE,
|
||||
IBUS_HANGUL_RETAINED_COMMIT_TRACE,
|
||||
IBUS_HANGUL_MIXED_LATIN_TRACE,
|
||||
TELEX_BACKSPACE_MID_COMPOSITION_TRACE,
|
||||
CANDIDATE_ARROW_NAVIGATION_TRACE
|
||||
]
|
||||
@@ -0,0 +1,568 @@
|
||||
{
|
||||
"committed": "한글",
|
||||
"env": {
|
||||
"browser": "chromium",
|
||||
"engine": "ibus-hangul",
|
||||
"platform": "linux"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"code": "KeyG",
|
||||
"isComposing": false,
|
||||
"key": "Process",
|
||||
"keyCode": 229,
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "compositionstart"
|
||||
},
|
||||
{
|
||||
"data": "ㅎ",
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "ㅎ",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "ㅎ",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "ㅎ"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "KeyG",
|
||||
"isComposing": true,
|
||||
"key": "g",
|
||||
"keyCode": 71,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "ㅎ"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "KeyK",
|
||||
"isComposing": true,
|
||||
"key": "k",
|
||||
"keyCode": 75,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "ㅎ"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"data": "하",
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 0,
|
||||
"value": "ㅎ"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "하",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 0,
|
||||
"value": "ㅎ"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "하",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "하"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "KeyS",
|
||||
"isComposing": true,
|
||||
"key": "s",
|
||||
"keyCode": 83,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "하"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"data": "한",
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 0,
|
||||
"value": "하"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "한",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 0,
|
||||
"value": "하"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "한",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "AltRight",
|
||||
"isComposing": true,
|
||||
"key": "Process",
|
||||
"keyCode": 229,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 0,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 0,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": null,
|
||||
"inputType": "deleteContentBackward",
|
||||
"isComposing": false,
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "compositionend"
|
||||
},
|
||||
{
|
||||
"data": "한",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"state": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "한",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "AltRight",
|
||||
"isComposing": false,
|
||||
"key": "HangulMode",
|
||||
"keyCode": 21,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "KeyA",
|
||||
"isComposing": false,
|
||||
"key": "a",
|
||||
"keyCode": 65,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"code": "KeyA",
|
||||
"isComposing": false,
|
||||
"key": "a",
|
||||
"keyCode": 65,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "KeyB",
|
||||
"isComposing": false,
|
||||
"key": "b",
|
||||
"keyCode": 66,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"code": "KeyB",
|
||||
"isComposing": false,
|
||||
"key": "b",
|
||||
"keyCode": 66,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "KeyC",
|
||||
"isComposing": false,
|
||||
"key": "c",
|
||||
"keyCode": 67,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"code": "KeyC",
|
||||
"isComposing": false,
|
||||
"key": "c",
|
||||
"keyCode": 67,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "AltRight",
|
||||
"isComposing": false,
|
||||
"key": "HangulMode",
|
||||
"keyCode": 21,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "KeyR",
|
||||
"isComposing": false,
|
||||
"key": "r",
|
||||
"keyCode": 82,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "compositionstart"
|
||||
},
|
||||
{
|
||||
"data": "ㄱ",
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "ㄱ",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "ㄱ",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한ㄱ"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "KeyM",
|
||||
"isComposing": true,
|
||||
"key": "Process",
|
||||
"keyCode": 229,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한ㄱ"
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"data": "그",
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 1,
|
||||
"value": "한ㄱ"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "그",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 1,
|
||||
"value": "한ㄱ"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "그",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한그"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "KeyM",
|
||||
"isComposing": true,
|
||||
"key": "m",
|
||||
"keyCode": 77,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한그"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"code": "KeyF",
|
||||
"isComposing": true,
|
||||
"key": "f",
|
||||
"keyCode": 70,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한그"
|
||||
},
|
||||
"type": "keyup"
|
||||
},
|
||||
{
|
||||
"data": "글",
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 1,
|
||||
"value": "한그"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "글",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 1,
|
||||
"value": "한그"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "글",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한글"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"code": "Enter",
|
||||
"isComposing": true,
|
||||
"key": "Process",
|
||||
"keyCode": 229,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한글"
|
||||
},
|
||||
"type": "keydown"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 1,
|
||||
"value": "한글"
|
||||
},
|
||||
"type": "compositionupdate"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"inputType": "insertCompositionText",
|
||||
"isComposing": true,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 1,
|
||||
"value": "한글"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": null,
|
||||
"inputType": "deleteContentBackward",
|
||||
"isComposing": false,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "input"
|
||||
},
|
||||
{
|
||||
"data": "",
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "compositionend"
|
||||
},
|
||||
{
|
||||
"data": "글",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"state": {
|
||||
"selectionEnd": 1,
|
||||
"selectionStart": 1,
|
||||
"value": "한"
|
||||
},
|
||||
"type": "beforeinput"
|
||||
},
|
||||
{
|
||||
"data": "글",
|
||||
"inputType": "insertText",
|
||||
"isComposing": false,
|
||||
"state": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한글"
|
||||
},
|
||||
"type": "input"
|
||||
}
|
||||
],
|
||||
"final": {
|
||||
"selectionEnd": 2,
|
||||
"selectionStart": 2,
|
||||
"value": "한글"
|
||||
},
|
||||
"initial": {
|
||||
"selectionEnd": 0,
|
||||
"selectionStart": 0,
|
||||
"value": ""
|
||||
},
|
||||
"name": "ibus-hangul terminal jamo assembly and commit",
|
||||
"origin": "Captured from a live ibus-hangul engine by tests/e2e/terminal-ime-boundary-probe.ts during the terminal-ime-e2e workflow (run 30691057123, ubuntu-22.04). xdotool types at the X11 layer so the engine really composes; the selection offsets are the engine's own, not a reconstruction. First repetition of the 한abc글 sequence, sliced at the last event before xterm clears the helper textarea. PTY oracle: ['한','a','b','c','글','\\r'].",
|
||||
"pty": ["한", "a", "b", "c", "글", "\r"],
|
||||
"provenance": "recorded"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Loads the raw ibus-hangul recording. The recording itself lives in the adjacent
|
||||
// .json because it is machine output, not source: re-capture it, never hand-edit it.
|
||||
//
|
||||
// Capture path: tests/e2e/terminal-ime-boundary-probe.ts reads the live
|
||||
// .xterm-helper-textarea while xdotool types at the X11 layer, so a real engine
|
||||
// composes and the selection offsets are its own rather than a reconstruction.
|
||||
// See the `origin` field in the JSON for the exact workflow run.
|
||||
|
||||
import type {
|
||||
ImeCompositionTrace,
|
||||
ImeTraceEvent,
|
||||
ImeTraceTargetState
|
||||
} from './ime-composition-trace.test-fixtures'
|
||||
import recording from './ime-recorded-ibus-hangul-trace.json'
|
||||
|
||||
const COMPOSITION_TYPES = new Set(['compositionend', 'compositionstart', 'compositionupdate'])
|
||||
const KEY_TYPES = new Set(['keydown', 'keypress', 'keyup'])
|
||||
const INPUT_TYPES = new Set(['beforeinput', 'input'])
|
||||
|
||||
/**
|
||||
* Narrows the JSON to the trace type. A blanket structural cast would hide the drift
|
||||
* this is here to catch: a re-captured recording that gained or lost a field would
|
||||
* type-check and then fail somewhere unrelated.
|
||||
*/
|
||||
function toTraceEvent(raw: (typeof recording)['events'][number], index: number): ImeTraceEvent {
|
||||
const state = raw.state as ImeTraceTargetState
|
||||
if (COMPOSITION_TYPES.has(raw.type)) {
|
||||
return {
|
||||
data: raw.data ?? '',
|
||||
state,
|
||||
type: raw.type as 'compositionend' | 'compositionstart' | 'compositionupdate'
|
||||
}
|
||||
}
|
||||
if (KEY_TYPES.has(raw.type)) {
|
||||
return {
|
||||
code: raw.code ?? '',
|
||||
isComposing: raw.isComposing === true,
|
||||
key: raw.key ?? '',
|
||||
keyCode: raw.keyCode ?? 0,
|
||||
state,
|
||||
type: raw.type as 'keydown' | 'keypress' | 'keyup'
|
||||
}
|
||||
}
|
||||
if (INPUT_TYPES.has(raw.type)) {
|
||||
return {
|
||||
data: raw.data ?? null,
|
||||
inputType: raw.inputType ?? '',
|
||||
isComposing: raw.isComposing === true,
|
||||
state,
|
||||
type: raw.type as 'beforeinput' | 'input'
|
||||
}
|
||||
}
|
||||
throw new Error(`Unrecognized recorded event type "${raw.type}" at index ${index}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Real jamo assembly followed by a commit. Three things here are not reproducible by
|
||||
* hand, which is the whole reason this recording exists:
|
||||
*
|
||||
* 1. The Enter that commits arrives as `key: 'Process'`, `keyCode: 229`,
|
||||
* `isComposing: true`. The submit Enter that follows the slice is `keyCode: 13`.
|
||||
* 2. ibus-hangul tears the preedit down with `deleteContentBackward` and only *then*
|
||||
* re-inserts the committed text with `insertText`. Anything deriving committed text
|
||||
* by diffing the buffer sees the delete and loses the character.
|
||||
* 3. `compositionend` carries `data: ''`, so it is not the commit carrier here.
|
||||
*/
|
||||
export const IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE: ImeCompositionTrace = {
|
||||
committed: recording.committed,
|
||||
env: {
|
||||
browser: 'chromium',
|
||||
engine: recording.env.engine,
|
||||
platform: 'linux'
|
||||
},
|
||||
events: recording.events.map(toTraceEvent),
|
||||
final: recording.final,
|
||||
initial: recording.initial,
|
||||
name: recording.name,
|
||||
origin: recording.origin,
|
||||
provenance: 'recorded'
|
||||
}
|
||||
|
||||
/** Bytes the terminal actually forwarded to the PTY during this recording. */
|
||||
export const IBUS_HANGUL_TERMINAL_PTY_ORACLE: readonly string[] = recording.pty
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
shouldSuppressEnterSubmit
|
||||
} from './new-workspace-enter-guard'
|
||||
|
||||
function makeEvent(overrides: Partial<{ isComposing: boolean; shiftKey: boolean }>): {
|
||||
isComposing: boolean
|
||||
shiftKey: boolean
|
||||
} {
|
||||
return { isComposing: false, shiftKey: false, ...overrides }
|
||||
function makeEvent(
|
||||
overrides: Partial<Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode' | 'shiftKey'>>
|
||||
): Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode' | 'shiftKey'> {
|
||||
return { isComposing: false, key: 'Enter', keyCode: 13, shiftKey: false, ...overrides }
|
||||
}
|
||||
|
||||
class FakeHTMLElement extends EventTarget {
|
||||
@@ -34,6 +33,11 @@ describe('shouldSuppressEnterSubmit', () => {
|
||||
expect(shouldSuppressEnterSubmit(makeEvent({ isComposing: true }), false)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for an IME-owned Enter carrying only the Windows 229 marker', () => {
|
||||
// Bare isComposing missed this; the central predicate is what catches it.
|
||||
expect(shouldSuppressEnterSubmit(makeEvent({ key: 'Process', keyCode: 229 }), false)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for Shift+Enter inside a textarea', () => {
|
||||
expect(shouldSuppressEnterSubmit(makeEvent({ shiftKey: true }), true)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isImeCompositionKeyDown } from './ime-composition-keyboard-event'
|
||||
|
||||
/**
|
||||
* Returns true when an Enter keydown event should be suppressed for submit actions.
|
||||
*
|
||||
@@ -6,10 +8,12 @@
|
||||
* 2. Shift+Enter inside a textarea — intended as a newline, not a submit.
|
||||
*/
|
||||
export function shouldSuppressEnterSubmit(
|
||||
event: { isComposing: boolean; shiftKey: boolean },
|
||||
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode' | 'shiftKey'>,
|
||||
isTextarea: boolean
|
||||
): boolean {
|
||||
if (event.isComposing) {
|
||||
// Delegates so these gates get the 229 / 'Process' markers and the live-composition
|
||||
// flag too; bare isComposing misses an IME-owned Enter on Windows and Android.
|
||||
if (isImeCompositionKeyDown(event)) {
|
||||
return true
|
||||
}
|
||||
if (isTextarea && event.shiftKey) {
|
||||
|
||||
Reference in New Issue
Block a user