mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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).
This commit is contained in:
+32
-8
@@ -61,6 +61,10 @@
|
||||
const _crashDiag = {
|
||||
_entries: [],
|
||||
_maxEntries: 50,
|
||||
// Per-page-load id: the server keys beacons by it, so a reload (fresh id)
|
||||
// archives the previous page's trail instead of overwriting it, and
|
||||
// concurrent clients (desktop + phone) don't clobber each other.
|
||||
_pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8),
|
||||
log(msg) {
|
||||
const entry = `${new Date().toISOString().slice(11,23)} ${msg}`;
|
||||
this._entries.push(entry);
|
||||
@@ -69,22 +73,36 @@ const _crashDiag = {
|
||||
}
|
||||
};
|
||||
|
||||
// Log previous crash breadcrumbs on startup
|
||||
// Log previous crash breadcrumbs on startup, and re-beacon them under a
|
||||
// distinct page id — an iOS PWA reload wipes the in-memory trail, and without
|
||||
// this the fresh page's first beacon would be all the server ever sees.
|
||||
try {
|
||||
const prev = localStorage.getItem('codeman-crash-diag');
|
||||
if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
if (prev) {
|
||||
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
|
||||
}
|
||||
} catch {}
|
||||
_crashDiag.log('PAGE LOAD');
|
||||
|
||||
// Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes.
|
||||
setInterval(() => {
|
||||
function _crashDiagBeacon() {
|
||||
try {
|
||||
localStorage.setItem('codeman-crash-heartbeat', String(Date.now()));
|
||||
if (_crashDiag._entries.length > 0) {
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') }));
|
||||
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setInterval(() => {
|
||||
try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {}
|
||||
_crashDiagBeacon();
|
||||
}, 2000);
|
||||
// iOS suspends JS the instant the app is backgrounded — entries logged since
|
||||
// the last 2s tick would sit unsent until (if ever) the page resumes. Flush
|
||||
// immediately on hide so a repro right before an app switch is never lost.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') _crashDiagBeacon();
|
||||
});
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
_crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`);
|
||||
@@ -3390,9 +3408,15 @@ class CodemanApp {
|
||||
// Close WebSocket for previous session (new one opens after buffer load)
|
||||
this._disconnectWs();
|
||||
|
||||
// Clear CJK textarea to prevent sending stale text to the wrong session
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) cjkEl.value = '';
|
||||
// Clear CJK input to prevent sending stale text to the wrong session.
|
||||
// Must go through CjkInput.clear() — a raw value wipe leaves the module's
|
||||
// pending flush timers armed and drops the phantom char it relies on.
|
||||
if (typeof CjkInput !== 'undefined') {
|
||||
CjkInput.clear();
|
||||
} else {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl) cjkEl.value = '';
|
||||
}
|
||||
|
||||
// Clean up flicker filter state when switching sessions
|
||||
this._clearTimer('flickerFilterTimeout');
|
||||
|
||||
+124
-5
@@ -53,13 +53,36 @@ const CjkInput = (() => {
|
||||
let _initialized = false;
|
||||
let _composing = false;
|
||||
let _flushTimer = null;
|
||||
let _compositionFlushTimer = null;
|
||||
let _dictationActive = false;
|
||||
let _dictationDecayTimer = null;
|
||||
let _keydownSentAt = 0;
|
||||
let _keydownSentText = '';
|
||||
const _listeners = {};
|
||||
|
||||
const PHANTOM = '';
|
||||
|
||||
// ── Diagnostic trace (intermittent CJK-loss investigation) ──
|
||||
// In-memory ring buffer of every IME event + flush decision. Mirrored into
|
||||
// the crash-diag breadcrumbs (app.js), which beacon to the server every 2s —
|
||||
// after a repro, `GET /api/crash-diag` shows the exact event sequence.
|
||||
const TRACE_MAX = 200;
|
||||
const _trace = [];
|
||||
function _esc(v) {
|
||||
const s = String(v == null ? '' : v).replace(//g, '∅');
|
||||
return JSON.stringify(s.length > 24 ? s.slice(0, 24) + '…' + s.length : s);
|
||||
}
|
||||
function _t(msg) {
|
||||
_trace.push(`${Date.now() % 1000000} ${msg}`);
|
||||
if (_trace.length > TRACE_MAX) _trace.shift();
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof _crashDiag !== 'undefined') _crashDiag.log('CJK ' + msg);
|
||||
} catch {
|
||||
/* crash-diag unavailable (tests) — ring buffer still records */
|
||||
}
|
||||
}
|
||||
|
||||
// Two-tier debounce for non-composition input:
|
||||
// - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire
|
||||
// composition events even for keyboard CJK typing)
|
||||
@@ -93,6 +116,16 @@ const CjkInput = (() => {
|
||||
}
|
||||
|
||||
function _resetToPhantom() {
|
||||
// Skip redundant writes: every programmatic value/selection mutation can
|
||||
// desync an Android IME's input session (InputConnection) — after which
|
||||
// the keyboard composes in its own UI but NO events ever reach the page.
|
||||
// Only touch the DOM when the content actually differs.
|
||||
if (_textarea.value === PHANTOM) {
|
||||
if (_textarea.selectionStart !== 1 || _textarea.selectionEnd !== 1) {
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_textarea.value = PHANTOM;
|
||||
_textarea.setSelectionRange(1, 1);
|
||||
}
|
||||
@@ -103,7 +136,17 @@ const CjkInput = (() => {
|
||||
|
||||
/** Flush textarea: send real text to PTY and reset to phantom */
|
||||
function _flush() {
|
||||
// Never flush mid-composition: reading the value would send the IME's
|
||||
// provisional text, and resetting the textarea cancels the in-progress
|
||||
// composition on iOS Safari — silently eating the character being typed.
|
||||
// Any committed-but-unflushed text stays in the textarea and is sent
|
||||
// together by the next compositionend flush.
|
||||
if (_composing) {
|
||||
_t('flush SKIP composing');
|
||||
return;
|
||||
}
|
||||
const val = _strip(_textarea.value);
|
||||
_t(`flush ${val ? 'send ' + _esc(val) : 'empty'}`);
|
||||
if (val) {
|
||||
_send(val);
|
||||
}
|
||||
@@ -150,12 +193,31 @@ const CjkInput = (() => {
|
||||
|
||||
_resetToPhantom();
|
||||
|
||||
_t('init v2-trace');
|
||||
|
||||
_listeners.mousedown = (e) => { e.stopPropagation(); };
|
||||
|
||||
// ── Wedged-IME recovery (Android) ──
|
||||
// Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their
|
||||
// InputConnection: the keyboard composes in its own candidate bar but
|
||||
// delivers ZERO DOM events to the focused textarea. JS cannot detect
|
||||
// this (nothing fires) — but re-tapping the already-focused empty field
|
||||
// is the user's natural "it's stuck" gesture. A blur→focus cycle forces
|
||||
// the browser to restart the IME input session, which un-wedges it.
|
||||
_listeners.pointerdown = () => {
|
||||
if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) {
|
||||
_t('ime-reset (retap)');
|
||||
_textarea.blur();
|
||||
setTimeout(() => _textarea.focus(), 0);
|
||||
}
|
||||
};
|
||||
_listeners.focus = () => {
|
||||
_t(`focus val=${_esc(_textarea.value)}`);
|
||||
window.cjkActive = true;
|
||||
if (!_textarea.value) _resetToPhantom();
|
||||
};
|
||||
_listeners.blur = () => {
|
||||
_t(`blur composing=${_composing} val=${_esc(_textarea.value)}`);
|
||||
// Keep cjkActive while CJK input is visible — iOS dictation and system
|
||||
// UI may steal focus temporarily, and clearing the flag during that
|
||||
// window lets xterm's onData process duplicated input.
|
||||
@@ -168,28 +230,38 @@ const CjkInput = (() => {
|
||||
_composing = false;
|
||||
};
|
||||
_textarea.addEventListener('mousedown', _listeners.mousedown);
|
||||
_textarea.addEventListener('pointerdown', _listeners.pointerdown);
|
||||
_textarea.addEventListener('focus', _listeners.focus);
|
||||
_textarea.addEventListener('blur', _listeners.blur);
|
||||
|
||||
// ── Composition tracking (keyboard IME — works for CJK typing) ──
|
||||
_listeners.compositionstart = () => {
|
||||
_t(`compstart val=${_esc(_textarea.value)}`);
|
||||
_composing = true;
|
||||
_cancelDebouncedFlush();
|
||||
// Leave textarea.value untouched — programmatic changes during
|
||||
// compositionstart cancel the IME composition on iOS Safari.
|
||||
};
|
||||
_listeners.compositionend = () => {
|
||||
_t(`compend val=${_esc(_textarea.value)}`);
|
||||
_composing = false;
|
||||
_cancelDebouncedFlush();
|
||||
// Defer flush: some Android IMEs haven't committed text to textarea
|
||||
// when compositionend fires. setTimeout(0) ensures we read the final value.
|
||||
setTimeout(_flush, 0);
|
||||
// Tracked so destroy() can cancel it; if the next composition starts
|
||||
// before it runs, _flush's _composing guard turns it into a no-op.
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = setTimeout(() => {
|
||||
_compositionFlushTimer = null;
|
||||
_flush();
|
||||
}, 0);
|
||||
};
|
||||
_textarea.addEventListener('compositionstart', _listeners.compositionstart);
|
||||
_textarea.addEventListener('compositionend', _listeners.compositionend);
|
||||
|
||||
// ── Keydown: special keys work REGARDLESS of composition state ──
|
||||
_listeners.keydown = (e) => {
|
||||
_t(`keydown ${_esc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_composing = false;
|
||||
@@ -246,6 +318,7 @@ const CjkInput = (() => {
|
||||
e.preventDefault();
|
||||
_send(e.key);
|
||||
_keydownSentAt = performance.now();
|
||||
_keydownSentText = e.key;
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
@@ -254,6 +327,23 @@ const CjkInput = (() => {
|
||||
|
||||
// ── Input event: primary path for virtual keyboards + dictation ──
|
||||
_listeners.input = (e) => {
|
||||
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} val=${_esc(_textarea.value)}`);
|
||||
// ── Stuck-composition recovery ──
|
||||
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
|
||||
// matching compositionend. A stale _composing=true blocks every flush
|
||||
// below — committed CJK text piles up in the textarea and never
|
||||
// reaches the PTY. When the event itself says composition is over
|
||||
// (isComposing false AND a non-composition inputType), trust it.
|
||||
if (
|
||||
_composing &&
|
||||
e.isComposing === false &&
|
||||
e.inputType !== 'insertCompositionText' &&
|
||||
e.inputType !== 'deleteCompositionText'
|
||||
) {
|
||||
_t('UNSTICK composing');
|
||||
_composing = false;
|
||||
}
|
||||
|
||||
// ── Backspace / delete detection ──
|
||||
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
|
||||
if (_composing) return;
|
||||
@@ -283,11 +373,18 @@ const CjkInput = (() => {
|
||||
|
||||
if (_composing) return;
|
||||
|
||||
// Keydown handler already sent this character — just clear the
|
||||
// textarea echo that the IME inserted despite preventDefault.
|
||||
// Keydown handler already sent this character — clear the textarea
|
||||
// echo that the IME inserted despite preventDefault. Content-checked:
|
||||
// only a value matching the sent char is an echo. Anything else (e.g.
|
||||
// an IME committing CJK text right after a keydown-sent char) is real
|
||||
// input and must flow through to the debounced flush, not be dropped.
|
||||
if (performance.now() - _keydownSentAt < 100) {
|
||||
_resetToPhantom();
|
||||
return;
|
||||
const cur = _strip(_textarea.value);
|
||||
if (cur === '' || cur === _keydownSentText) {
|
||||
_t('echo-drop');
|
||||
_resetToPhantom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Outside composition: keyboard typing or voice dictation.
|
||||
@@ -301,8 +398,30 @@ const CjkInput = (() => {
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Discard pending text and timers (e.g. on session switch, so stale text
|
||||
* can't flush into the wrong session). Restores the phantom so backspace
|
||||
* forwarding keeps working — unlike a raw `textarea.value = ''`.
|
||||
*/
|
||||
clear() {
|
||||
if (!_initialized || !_textarea) return;
|
||||
_t('clear (external)');
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
_composing = false;
|
||||
_resetToPhantom();
|
||||
},
|
||||
|
||||
/** Diagnostic: recent IME event trace (ring buffer). */
|
||||
getTrace() {
|
||||
return _trace.slice();
|
||||
},
|
||||
|
||||
destroy() {
|
||||
_cancelDebouncedFlush();
|
||||
clearTimeout(_compositionFlushTimer);
|
||||
_compositionFlushTimer = null;
|
||||
clearTimeout(_dictationDecayTimer);
|
||||
_dictationActive = false;
|
||||
if (_textarea) {
|
||||
|
||||
@@ -305,6 +305,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Focus router ──
|
||||
// While the CJK field is visible, EVERY terminal.focus() call must land on
|
||||
// the CJK field instead. Focusing xterm's hidden textarea in CJK mode sends
|
||||
// the IME's output into a black hole: the keyboard composes normally, but
|
||||
// onData is gated by cjkActive, so nothing reaches the field OR the PTY.
|
||||
// Session select / SSE-reconnect restore paths call terminal.focus() and
|
||||
// were silently stealing focus after every app switch on mobile (the
|
||||
// intermittent "Chinese input goes nowhere" bug). One chokepoint here
|
||||
// covers all ~15 call sites plus any future ones.
|
||||
const _xtermFocus = this.terminal.focus.bind(this.terminal);
|
||||
this.terminal.focus = () => {
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl?.classList.contains('cjk-input-visible')) {
|
||||
cjkEl.focus();
|
||||
} else {
|
||||
_xtermFocus();
|
||||
}
|
||||
};
|
||||
|
||||
// On mobile Safari, delay initial fit() to allow layout to settle
|
||||
// This prevents 0-column terminals caused by fit() running before container is sized
|
||||
const isMobileSafari =
|
||||
@@ -627,7 +646,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
// is on, because cjkActive stays true the whole time the field is visible.
|
||||
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
|
||||
// CJK input has focus — block xterm from sending keystrokes to PTY
|
||||
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return;
|
||||
if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) {
|
||||
// Self-heal: if the CJK field is visible but focus drifted to xterm's
|
||||
// hidden textarea (e.g. something called terminal.focus()), everything
|
||||
// typed lands HERE and is swallowed — keyboard shows the IME composing
|
||||
// while both the CJK field and the terminal stay empty. Route focus
|
||||
// back so the very next keystroke lands in the CJK field again.
|
||||
const cjkEl = document.getElementById('cjkInput');
|
||||
if (cjkEl?.classList.contains('cjk-input-visible') && document.activeElement !== cjkEl) {
|
||||
_crashDiag.log('CJK regain-focus (onData swallowed input)');
|
||||
cjkEl.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.activeSessionId) {
|
||||
// Filter terminal query replies generated by xterm.js itself.
|
||||
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
|
||||
@@ -1622,7 +1653,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// CJK textarea already provides visual feedback — bypass local echo
|
||||
// buffering so each composed word reaches the PTY immediately.
|
||||
_handleCjkInput(text) {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!this.activeSessionId) {
|
||||
_crashDiag.log(`CJK send DROP no-session len=${text.length}`);
|
||||
return;
|
||||
}
|
||||
_crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
|
||||
this._sendInputAsync(this.activeSessionId, text);
|
||||
},
|
||||
|
||||
|
||||
+21
-4
@@ -834,7 +834,13 @@ export class WebServer extends EventEmitter {
|
||||
// Keep the body as a RAW STRING and parse it inside the handler — a global
|
||||
// text/plain -> JSON parser would let a cross-site "simple request" (no CORS
|
||||
// preflight) submit JSON to any route. See security review C2.
|
||||
let _crashBreadcrumbs = '';
|
||||
// Keyed by the client's per-page-load id so (a) a page reload (fresh id)
|
||||
// ARCHIVES the previous page's breadcrumbs instead of overwriting them —
|
||||
// iOS PWA reloads used to wipe the trace of the very bug being chased —
|
||||
// and (b) concurrent clients (desktop + phone) don't clobber each other.
|
||||
// Same id replaces in place (each beacon carries the full ring buffer).
|
||||
const MAX_CRASH_PAGES = 10;
|
||||
const _crashPages = new Map<string, { at: number; data: string }>();
|
||||
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
|
||||
done(null, body);
|
||||
});
|
||||
@@ -844,17 +850,28 @@ export class WebServer extends EventEmitter {
|
||||
this.app.post('/api/crash-diag', (req, reply) => {
|
||||
const raw = typeof req.body === 'string' ? req.body : '';
|
||||
let data = raw;
|
||||
let pageId = 'legacy';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { data?: unknown };
|
||||
const parsed = JSON.parse(raw) as { data?: unknown; id?: unknown };
|
||||
if (parsed && typeof parsed.data === 'string') data = parsed.data;
|
||||
if (parsed && typeof parsed.id === 'string' && parsed.id) pageId = parsed.id.slice(0, 64);
|
||||
} catch {
|
||||
/* not JSON — treat the raw beacon text as the breadcrumbs */
|
||||
}
|
||||
_crashBreadcrumbs = String(data || '');
|
||||
_crashPages.delete(pageId); // re-insert = move to MRU end
|
||||
_crashPages.set(pageId, { at: Date.now(), data: String(data || '') });
|
||||
if (_crashPages.size > MAX_CRASH_PAGES) {
|
||||
const oldest = _crashPages.keys().next().value;
|
||||
if (oldest !== undefined) _crashPages.delete(oldest);
|
||||
}
|
||||
reply.code(204).send();
|
||||
});
|
||||
this.app.get('/api/crash-diag', (_req, reply) => {
|
||||
reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() });
|
||||
const pages = [..._crashPages.entries()].map(([id, p]) => ({ id, at: p.at, data: p.data }));
|
||||
const breadcrumbs = pages
|
||||
.map((p) => `═══ page ${p.id} (last beacon ${new Date(p.at).toISOString()}) ═══\n${p.data}`)
|
||||
.join('\n\n');
|
||||
reply.code(200).send({ breadcrumbs, pages, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// Register all route modules
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user