mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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).
242 lines
8.4 KiB
TypeScript
242 lines
8.4 KiB
TypeScript
/**
|
||
* @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);
|
||
});
|
||
});
|