From 84ebd7159576e1a2365e6284e10cbe82516c37f2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:44:52 -0700 Subject: [PATCH] test(ime): stop the trace harness discarding fields it records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ime-composition-trace.test-fixtures.ts | 17 +++-- .../src/lib/ime-composition-trace.test.ts | 76 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts b/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts index 35051982fad..bccbafa1cf3 100644 --- a/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts +++ b/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts @@ -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) } /** diff --git a/src/renderer/src/lib/ime-composition-trace.test.ts b/src/renderer/src/lib/ime-composition-trace.test.ts index a1bb2318c90..586be19a823 100644 --- a/src/renderer/src/lib/ime-composition-trace.test.ts +++ b/src/renderer/src/lib/ime-composition-trace.test.ts @@ -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']) + }) })