Files
Codeman/test/input-cjk.test.ts
T
Teigen 443b85c18e fix(mobile): CJK input loss — IME state machine, focus routing, and Android InputConnection recovery
Three independent root causes of intermittent Chinese character loss
(English was unaffected because it bypasses the composition path):

1. input-cjk.js state machine: stuck _composing when compositionend never
   fires (WeChat/Sogou IMEs) silently swallowed all input; the deferred
   compositionend flush could reset the textarea mid-next-composition
   (cancels the live IME composition on iOS); the 100ms keydown-echo
   window discarded ANY input regardless of content.

2. Focus stealing: session-select / SSE-reconnect paths call
   terminal.focus() (15+ call sites), landing focus on xterm's hidden
   textarea; with the CJK onData gate active, everything typed there was
   swallowed. Fix: focus router in initTerminal routes ALL
   terminal.focus() calls to the CJK field while it is visible, plus a
   self-healing onData gate that reclaims focus when it swallows input.

3. Android InputConnection wedge (9-key IMEs + Chromium): the keyboard
   composes in its own UI but delivers zero DOM events. Fix: skip
   redundant textarea value/selection writes (they race IME session
   setup), and re-tapping the focused empty field forces a blur→focus
   cycle that restarts the input session.

Diagnostics: input-cjk.js now traces every IME event/flush decision into
the crash-diag breadcrumbs; /api/crash-diag stores beacons per page-load
id (iOS PWA reloads no longer wipe the trail, concurrent clients no
longer clobber each other) and flushes on visibilitychange.

Tests: test/input-cjk.test.ts (vm-sandbox, 9 cases incl. regression
guards for all three root causes).
2026-07-07 17:52:10 +08:00

242 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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<string, unknown>) => void;
}
function makeTextarea(): FakeTextarea {
const listeners = new Map<string, Array<(e?: unknown) => 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<string, unknown> = {};
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<typeof setTimeout>) => 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);
});
});