/** * @fileoverview Unit tests for the CJK IME input module (input-cjk.js). * * Loads the browser module into a vm sandbox (no real DOM) and drives fake * composition/keydown/input event sequences against a stub textarea. * Focus: the intermittent "Chinese characters silently lost" failure modes — * stuck composition state, deferred flush racing the next composition, and * the keydown-echo suppression window swallowing a real IME commit. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const PHANTOM = '​'; interface FakeTextarea { value: string; valueWrites: number; blur: () => void; focus: () => void; classList: { contains: (c: string) => boolean }; setSelectionRange: () => void; addEventListener: (ev: string, fn: (e?: unknown) => void) => void; removeEventListener: (ev: string, fn: (e?: unknown) => void) => void; fire: (ev: string, arg?: Record) => void; } function makeTextarea(): FakeTextarea { const listeners = new Map void>>(); let val = ''; return { get value() { return val; }, set value(v: string) { this.valueWrites++; val = v; }, valueWrites: 0, blur: () => {}, focus: () => {}, classList: { contains: (c: string) => c === 'cjk-input-visible' }, setSelectionRange: () => {}, addEventListener: (ev, fn) => { const list = listeners.get(ev) ?? []; listeners.set(ev, [...list, fn]); }, removeEventListener: (ev, fn) => { const list = listeners.get(ev) ?? []; listeners.set( ev, list.filter((f) => f !== fn) ); }, fire(ev, arg = {}) { const base = { preventDefault: () => {}, stopPropagation: () => {}, ...arg }; for (const fn of listeners.get(ev) ?? []) fn(base); }, }; } function loadCjkHarness() { const textarea = makeTextarea(); const sent: string[] = []; const windowObj: Record = {}; const documentObj = { getElementById: (id: string) => (id === 'cjkInput' ? textarea : null), activeElement: null as unknown, }; // Delegating closures so vitest fake timers (which patch the test realm's // globals) govern the vm context's timers/clock too. const context = vm.createContext({ window: windowObj, document: documentObj, setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms), clearTimeout: (t: ReturnType) => clearTimeout(t), performance: { now: () => performance.now() }, console, }); const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/input-cjk.js'), 'utf8'); vm.runInContext(src, context, { filename: 'input-cjk.js' }); const CjkInput = vm.runInContext('CjkInput', context); CjkInput.init({ send: (text: string) => sent.push(text) }); textarea.fire('focus'); return { CjkInput, textarea, sent, windowObj, documentObj }; } describe('CJK input module', () => { beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }); }); afterEach(() => { vi.useRealTimers(); }); it('sends committed text after a normal composition cycle', () => { const { textarea, sent } = loadCjkHarness(); textarea.fire('compositionstart'); textarea.value = PHANTOM + '你好'; textarea.fire('input', { isComposing: true, inputType: 'insertCompositionText' }); textarea.fire('compositionend'); vi.advanceTimersByTime(10); expect(sent).toEqual(['你好']); expect(textarea.value).toBe(PHANTOM); }); it('recovers committed text when compositionend never fires (stuck composition)', () => { const { textarea, sent } = loadCjkHarness(); // IME fires compositionstart but never compositionend (WeChat/Sogou quirk). textarea.fire('compositionstart'); textarea.value = PHANTOM + '你好'; // The commit arrives as a plain input event outside composition. textarea.fire('input', { isComposing: false, inputType: 'insertText' }); vi.advanceTimersByTime(200); expect(sent).toEqual(['你好']); }); it('does not flush or reset the textarea while the next composition is active', () => { const { textarea, sent } = loadCjkHarness(); // Word 1 commits; the deferred flush is queued but has not run yet. textarea.fire('compositionstart'); textarea.value = PHANTOM + '你好'; textarea.fire('compositionend'); // Word 2 composition begins immediately (fast continuous typing). textarea.fire('compositionstart'); textarea.value = PHANTOM + '你好世界'; // Deferred flush from word 1 fires now — it must NOT touch the textarea // (a programmatic reset here cancels the live IME composition on iOS). vi.advanceTimersByTime(10); expect(sent).toEqual([]); expect(textarea.value).toBe(PHANTOM + '你好世界'); textarea.fire('compositionend'); vi.advanceTimersByTime(10); expect(sent).toEqual(['你好世界']); }); it('does not discard an IME commit landing inside the keydown echo window', () => { const { textarea, sent } = loadCjkHarness(); // English char goes out immediately via keydown. textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false }); expect(sent).toEqual(['a']); // Within 100ms the IME commits Chinese via a bare input event. vi.advanceTimersByTime(50); textarea.value = PHANTOM + '你好'; textarea.fire('input', { isComposing: false, inputType: 'insertText' }); vi.advanceTimersByTime(200); expect(sent).toEqual(['a', '你好']); }); it('still suppresses the true textarea echo of a keydown-sent character', () => { const { textarea, sent } = loadCjkHarness(); textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false }); expect(sent).toEqual(['a']); // Third-party IME ignored preventDefault — the same char echoes into // the textarea. It must be dropped, not sent twice. vi.advanceTimersByTime(10); textarea.value = PHANTOM + 'a'; textarea.fire('input', { isComposing: false, inputType: 'insertText' }); vi.advanceTimersByTime(300); expect(sent).toEqual(['a']); expect(textarea.value).toBe(PHANTOM); }); it('re-tapping the focused empty field restarts the IME session (wedged-IME recovery)', () => { const { textarea, documentObj } = loadCjkHarness(); documentObj.activeElement = textarea; let blurred = 0; let focused = 0; textarea.blur = () => { blurred++; }; textarea.focus = () => { focused++; }; textarea.fire('pointerdown'); expect(blurred).toBe(1); vi.advanceTimersByTime(10); expect(focused).toBe(1); // First tap on an unfocused field must NOT cycle (normal focus flow). documentObj.activeElement = null; textarea.fire('pointerdown'); expect(blurred).toBe(1); }); it('skips redundant textarea writes when already reset (Android IME desync guard)', () => { const { CjkInput, textarea } = loadCjkHarness(); const before = textarea.valueWrites; // Value is already the phantom — external clears (session switch / SSE // reconnect) must not rewrite it, or they race the IME session setup. CjkInput.clear(); CjkInput.clear(); expect(textarea.valueWrites).toBe(before); }); it('routes terminal.focus() to the CJK field while it is visible (focus router)', () => { // Regression guard for the intermittent "Chinese input goes nowhere" bug: // session-select / SSE-reconnect paths call terminal.focus(), which lands // on xterm's hidden textarea; with the CJK gate active, everything typed // there is swallowed. terminal-ui must (a) route focus() to the CJK field // when visible and (b) self-heal inside the onData gate. const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); expect(src).toContain('Focus router'); expect(src).toMatch(/this\.terminal\.focus = \(\) => \{/); expect(src).toContain("cjkEl?.classList.contains('cjk-input-visible')"); expect(src).toContain('CJK regain-focus (onData swallowed input)'); }); it('sends text plus carriage return on Enter', () => { const { textarea, sent } = loadCjkHarness(); textarea.value = PHANTOM + '你好'; textarea.fire('keydown', { key: 'Enter' }); expect(sent).toEqual(['你好\r']); expect(textarea.value).toBe(PHANTOM); }); });