test(ime): stop the trace harness discarding fields it records

The schema could not express repeat or selectionDirection, and the replayer
coerced an absent isComposing to false — the one distinction the field is
typed `boolean | undefined` to capture.

repeat is what a press-and-hold accent panel is recognised by, so a trace
recorded for that quirk could not reproduce it.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-01 21:47:23 -07:00
co-authored by Orca
parent e5568d899b
commit 84ebd71595
2 changed files with 87 additions and 6 deletions
@@ -20,6 +20,8 @@ export type ImeTraceTargetState = {
value: string
selectionStart: number
selectionEnd: number
/** Absent means 'none'. Without it a preedit range can only ever be a caret. */
selectionDirection?: 'backward' | 'forward' | 'none'
}
type ImeTraceEventBase = { state: ImeTraceTargetState }
@@ -30,6 +32,8 @@ export type ImeTraceKeyEvent = ImeTraceEventBase & {
code: string
keyCode: number
isComposing: boolean
/** macOS press-and-hold drives its accent panel off this; absent means false. */
repeat?: boolean
altKey?: boolean
ctrlKey?: boolean
metaKey?: boolean
@@ -88,13 +92,12 @@ function applyLegacyKeyCode(event: KeyboardEvent, keyCode: number): void {
}
function applyIsComposing(event: Event, isComposing: boolean | undefined): void {
if (
isComposing === undefined ||
(event as { isComposing?: boolean }).isComposing === isComposing
) {
if ((event as { isComposing?: boolean }).isComposing === isComposing) {
return
}
// Testing libraries cannot set isComposing any other way.
// Testing libraries cannot set isComposing any other way. `undefined` is a real
// recorded value, not a missing one: the constructor coerces it to false, which
// is the one thing a Safari trace exists to tell apart.
Object.defineProperty(event, 'isComposing', { configurable: true, value: isComposing })
}
@@ -109,6 +112,7 @@ export function createImeTraceKeyboardEvent(event: ImeTraceKeyEvent): KeyboardEv
key: event.key,
keyCode: event.keyCode,
metaKey: event.metaKey ?? false,
repeat: event.repeat ?? false,
shiftKey: event.shiftKey ?? false
})
applyLegacyKeyCode(created, event.keyCode)
@@ -170,6 +174,7 @@ export type ImeTraceReplayOptions = {
function readTargetState(target: HTMLTextAreaElement | HTMLInputElement): ImeTraceTargetState {
return {
selectionDirection: target.selectionDirection ?? 'none',
selectionEnd: target.selectionEnd ?? 0,
selectionStart: target.selectionStart ?? 0,
value: target.value
@@ -181,7 +186,7 @@ function stampTargetState(
state: ImeTraceTargetState
): void {
target.value = state.value
target.setSelectionRange(state.selectionStart, state.selectionEnd)
target.setSelectionRange(state.selectionStart, state.selectionEnd, state.selectionDirection)
}
/**
@@ -1,7 +1,10 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from 'vitest'
import {
createImeTraceInputEvent,
createImeTraceKeyboardEvent,
createImeTraceTextarea,
EMPTY_IME_TRACE_STATE,
extractImeCommitsFromTrace,
interpretImeCommits,
replayImeCompositionTrace
@@ -15,6 +18,15 @@ import {
IME_COMPOSITION_TRACES
} from './ime-recorded-composition-traces.test-fixtures'
const KEY_EVENT_WITHOUT_REPEAT: ImeTraceKeyEvent = {
code: 'KeyE',
isComposing: false,
key: 'e',
keyCode: 69,
state: EMPTY_IME_TRACE_STATE,
type: 'keydown'
}
afterEach(() => {
document.body.replaceChildren()
})
@@ -191,4 +203,68 @@ describe('replayImeCompositionTrace', () => {
// Process keys are composing; the interleaved 'a' and the trailing Enter are not.
expect(composing).toEqual([true, false, true, false])
})
it("replays Safari's undefined isComposing as undefined rather than false", () => {
// The whole reason the field is `boolean | undefined`: code that branches on
// `=== undefined` must see the two apart, and the constructor coerces.
const safari = createImeTraceInputEvent({
data: 'a',
inputType: 'insertText',
isComposing: undefined,
state: EMPTY_IME_TRACE_STATE,
type: 'input'
})
const chromium = createImeTraceInputEvent({
data: 'a',
inputType: 'insertText',
isComposing: false,
state: EMPTY_IME_TRACE_STATE,
type: 'input'
})
expect(safari.isComposing).toBeUndefined()
expect(chromium.isComposing).toBe(false)
})
it('replays the repeat flag macOS press-and-hold is distinguished by', () => {
const held = createImeTraceKeyboardEvent({
code: 'KeyE',
isComposing: false,
key: 'e',
keyCode: 69,
repeat: true,
state: EMPTY_IME_TRACE_STATE,
type: 'keydown'
})
expect(held.repeat).toBe(true)
expect(createImeTraceKeyboardEvent(KEY_EVENT_WITHOUT_REPEAT).repeat).toBe(false)
})
it('replays a backward selection so a preedit range is not flattened to a caret', async () => {
const textarea = createImeTraceTextarea(document)
const directions: (string | null)[] = []
textarea.addEventListener('keydown', () => directions.push(textarea.selectionDirection))
await replayImeCompositionTrace(textarea, {
...IBUS_HANGUL_RETAINED_COMMIT_TRACE,
events: [
{
code: 'KeyA',
isComposing: false,
key: 'a',
keyCode: 65,
state: {
selectionDirection: 'backward',
selectionEnd: 2,
selectionStart: 0,
value: 'ab'
},
type: 'keydown'
}
]
})
expect(directions).toEqual(['backward'])
})
})