mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
Merge pull request #499 from aakhter/pr/mobile-ime-preview
feat(terminal): preview IME composition text on iOS Safari # Conflicts: # CLAUDE.md
This commit is contained in:
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
|
||||
*
|
||||
* The controller itself is covered by test/mobile-ime-preview.test.ts. These
|
||||
* pin how terminal-ui.js and the delivery graph consume it: script order,
|
||||
* build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
|
||||
* the onData routing of an IME commit, and the rule that only output accepted
|
||||
* AFTER a commit may clear the committed preview.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
|
||||
const indexSource = read('src/web/public/index.html');
|
||||
const buildSource = read('scripts/build.mjs');
|
||||
const terminalSource = read('src/web/public/terminal-ui.js');
|
||||
const cssSource = read('src/web/public/styles.css');
|
||||
|
||||
type Fn = ReturnType<typeof vi.fn>;
|
||||
type App = Record<string, any>;
|
||||
|
||||
function loadMixin(globals: Record<string, unknown> = {}) {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const windowStub = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
...(globals.window as object),
|
||||
} as Record<string, unknown>;
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
URLSearchParams,
|
||||
location: { search: '' },
|
||||
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
document: { addEventListener: vi.fn(), createElement: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
CodemanApp: FakeCodemanApp,
|
||||
_crashDiag: { log: vi.fn() },
|
||||
...globals,
|
||||
window: windowStub,
|
||||
});
|
||||
vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
|
||||
return { mixin: FakeCodemanApp.prototype, context, windowStub };
|
||||
}
|
||||
|
||||
function fakeClassList() {
|
||||
const values = new Set<string>();
|
||||
return {
|
||||
add: vi.fn((value: string) => values.add(value)),
|
||||
remove: vi.fn((value: string) => values.delete(value)),
|
||||
toggle: vi.fn((value: string, force?: boolean) => {
|
||||
const enabled = force === undefined ? !values.has(value) : force;
|
||||
if (enabled) values.add(value);
|
||||
else values.delete(value);
|
||||
return enabled;
|
||||
}),
|
||||
contains: (value: string) => values.has(value),
|
||||
};
|
||||
}
|
||||
|
||||
function createPreviewHarness(
|
||||
options: {
|
||||
eligible?: boolean;
|
||||
createThrows?: boolean;
|
||||
omitGlobal?: boolean;
|
||||
themeForeground?: string;
|
||||
themeBackground?: string;
|
||||
themeGetterThrows?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const compositionView = {
|
||||
style: {
|
||||
fontFamily: '"Fira Code"',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: '12px',
|
||||
height: '12px',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
},
|
||||
};
|
||||
const helpers = {
|
||||
classList: fakeClassList(),
|
||||
children: [] as Array<Record<string, unknown>>,
|
||||
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
|
||||
appendChild(node: Record<string, unknown>) {
|
||||
this.children.push(node);
|
||||
},
|
||||
};
|
||||
const createdControllers: Array<Record<string, any>> = [];
|
||||
const previewNodes: Array<Record<string, any>> = [];
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement: vi.fn(() => {
|
||||
const node = {
|
||||
className: '',
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
dataset: {} as Record<string, string>,
|
||||
style: {} as Record<string, string>,
|
||||
attributes: {} as Record<string, string>,
|
||||
setAttribute(name: string, value: string) {
|
||||
this.attributes[name] = value;
|
||||
},
|
||||
remove: vi.fn(),
|
||||
};
|
||||
previewNodes.push(node);
|
||||
return node;
|
||||
}),
|
||||
};
|
||||
const mobileImePreview = options.omitGlobal
|
||||
? undefined
|
||||
: {
|
||||
isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
|
||||
create: vi.fn((callbacks: Record<string, unknown>) => {
|
||||
if (options.createThrows) throw new Error('controller unavailable');
|
||||
const controller = {
|
||||
destroy: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
consumeTerminalData: vi.fn(() => false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
callbacks,
|
||||
};
|
||||
createdControllers.push(controller);
|
||||
return controller;
|
||||
}),
|
||||
};
|
||||
const { mixin, windowStub } = loadMixin({
|
||||
document: documentStub,
|
||||
MobileImePreview: mobileImePreview,
|
||||
getComputedStyle: (node: { style: Record<string, string> }) => node.style,
|
||||
});
|
||||
windowStub.codemanCurrentXtermTheme = () => {
|
||||
if (options.themeGetterThrows) throw new Error('theme unavailable');
|
||||
return { foreground: '#334455', background: '#223344' };
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
options: {
|
||||
theme:
|
||||
options.themeForeground || options.themeBackground
|
||||
? { foreground: options.themeForeground, background: options.themeBackground }
|
||||
: undefined,
|
||||
},
|
||||
element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
});
|
||||
return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
|
||||
}
|
||||
|
||||
describe('mobile IME preview delivery graph', () => {
|
||||
it('loads the controller after xterm and before terminal wiring', () => {
|
||||
const at = indexSource.indexOf('<script defer src="mobile-ime-preview.js"></script>');
|
||||
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
|
||||
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
|
||||
});
|
||||
|
||||
it('is minified and content-hashed by the build', () => {
|
||||
expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
|
||||
expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
|
||||
});
|
||||
|
||||
it('scopes preview presentation and native composition suppression to touch ownership', () => {
|
||||
expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
|
||||
expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
|
||||
expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
|
||||
const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
|
||||
expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
|
||||
});
|
||||
|
||||
it('initializes the preview right after the terminal opens', () => {
|
||||
expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME preview lifecycle', () => {
|
||||
it('fails open when the global is absent or create throws', () => {
|
||||
for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
|
||||
const { app } = createPreviewHarness(options);
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create a controller on unsupported input platforms', () => {
|
||||
const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).not.toHaveBeenCalled();
|
||||
expect(previewNodes).toHaveLength(0);
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
});
|
||||
|
||||
it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
|
||||
const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
expect(mobileImePreview?.create).toHaveBeenCalledOnce();
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
|
||||
// Keydown is observed on the terminal element (an ancestor of the
|
||||
// textarea), so it runs before xterm's own capture listener finalizes.
|
||||
expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
|
||||
expect(helpers.children).toEqual([previewNodes[0]]);
|
||||
expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
|
||||
expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
|
||||
});
|
||||
|
||||
it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const first = createdControllers[0];
|
||||
app._initMobileImePreview();
|
||||
expect(first.destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(createdControllers).toHaveLength(2);
|
||||
expect(app._mobileImePreview).toBe(createdControllers[1]);
|
||||
// Window listeners are released with the controller that owned them.
|
||||
expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
|
||||
'offline',
|
||||
'pagehide',
|
||||
]);
|
||||
});
|
||||
|
||||
it('destroy releases the controller, the node and the listeners', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app._destroyMobileImePreview();
|
||||
expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
|
||||
expect(previewNodes[0].remove).toHaveBeenCalledOnce();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
|
||||
const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
|
||||
expect(pagehide).toBeTypeOf('function');
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
expect(createdControllers[0].destroy).not.toHaveBeenCalled();
|
||||
expect(previewNodes[0].remove).not.toHaveBeenCalled();
|
||||
expect(app._mobileImePreview).toBe(createdControllers[0]);
|
||||
// A second hide after the page came back from the cache still works.
|
||||
pagehide();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets the controller exactly once when the active session changes', () => {
|
||||
const { app, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
app.activeSessionId = 'session-b';
|
||||
app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
|
||||
app.sessions = new Map();
|
||||
app._updateLocalEchoState();
|
||||
app._updateLocalEchoState();
|
||||
expect(createdControllers[0].reset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders and clears owned preview state', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '你好', phase: 'provisional' });
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
// With local echo on, typed text sits in the overlay (z-index 7) and the PTY
|
||||
// cursor that places the span stays at the prompt start, under that text. The
|
||||
// overlay draws the composition instead. Real-xterm proof of the covering:
|
||||
// test/mobile-ime-preview.browser.test.ts.
|
||||
function withOverlay(app: App, visible = true) {
|
||||
const overlay = {
|
||||
composition: '',
|
||||
setComposition: vi.fn(function (this: { composition: string }, text: string) {
|
||||
this.composition = text;
|
||||
}),
|
||||
state: { visible },
|
||||
};
|
||||
Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
|
||||
return overlay;
|
||||
}
|
||||
|
||||
it('routes the preview into the local echo overlay when local echo is on', () => {
|
||||
const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app);
|
||||
app._initMobileImePreview();
|
||||
const callbacks = createdControllers[0].callbacks;
|
||||
callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
|
||||
callbacks.clear();
|
||||
expect(overlay.setComposition).toHaveBeenLastCalledWith('');
|
||||
expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
const overlay = withOverlay(app, false);
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
|
||||
expect(overlay.composition).toBe('');
|
||||
expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
|
||||
const off = createPreviewHarness();
|
||||
const offOverlay = withOverlay(off.app);
|
||||
Object.assign(off.app, { _localEchoEnabled: false });
|
||||
off.app._initMobileImePreview();
|
||||
off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(offOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
|
||||
const passthrough = createPreviewHarness();
|
||||
const passOverlay = withOverlay(passthrough.app);
|
||||
Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
|
||||
passthrough.app._initMobileImePreview();
|
||||
passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
|
||||
expect(passOverlay.setComposition).not.toHaveBeenCalled();
|
||||
expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
|
||||
});
|
||||
|
||||
it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
|
||||
const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
|
||||
themeForeground: '#1f2328',
|
||||
themeBackground: '#fafafa',
|
||||
});
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({
|
||||
fontFamily: compositionView.style.fontFamily,
|
||||
fontSize: compositionView.style.fontSize,
|
||||
fontWeight: compositionView.style.fontWeight,
|
||||
fontStyle: compositionView.style.fontStyle,
|
||||
lineHeight: compositionView.style.lineHeight,
|
||||
height: compositionView.style.height,
|
||||
color: '#1f2328',
|
||||
backgroundColor: '#fafafa',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the current skin theme when the terminal options carry no theme', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness();
|
||||
app._initMobileImePreview();
|
||||
createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
|
||||
expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
|
||||
});
|
||||
|
||||
it('keeps rendering with a safe foreground when the theme getter throws', () => {
|
||||
const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
|
||||
app._initMobileImePreview();
|
||||
expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
|
||||
expect(previewNodes[0].textContent).toBe('安全');
|
||||
expect(previewNodes[0].style.color).toBe('#e0e0e0');
|
||||
expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
|
||||
});
|
||||
|
||||
it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
|
||||
'removes partial DOM ownership when %s fails',
|
||||
(failure) => {
|
||||
const removed = vi.fn();
|
||||
const owner = fakeClassList();
|
||||
const preview = new Proxy(
|
||||
{ dataset: {}, remove: removed, setAttribute: vi.fn() },
|
||||
{
|
||||
set(target, property, value) {
|
||||
if (property === failure) throw new Error(`${failure} failed`);
|
||||
return Reflect.set(target, property, value);
|
||||
},
|
||||
}
|
||||
);
|
||||
const helpers = {
|
||||
classList: owner,
|
||||
appendChild:
|
||||
failure === 'append'
|
||||
? () => {
|
||||
throw new Error('append failed');
|
||||
}
|
||||
: vi.fn(),
|
||||
};
|
||||
const documentStub = {
|
||||
addEventListener: vi.fn(),
|
||||
createElement:
|
||||
failure === 'create'
|
||||
? () => {
|
||||
throw new Error('create failed');
|
||||
}
|
||||
: () => preview,
|
||||
};
|
||||
const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
|
||||
const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
terminal: {
|
||||
textarea: {},
|
||||
element: {
|
||||
querySelector:
|
||||
failure === 'query'
|
||||
? () => {
|
||||
throw new Error('query failed');
|
||||
}
|
||||
: () => helpers,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => app._initMobileImePreview()).not.toThrow();
|
||||
expect(app._mobileImePreview).toBeNull();
|
||||
expect(MobileImePreview.create).not.toHaveBeenCalled();
|
||||
expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
|
||||
if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebuilds the real `handleTerminalData` closure from initTerminal's source,
|
||||
* so these cases exercise the shipped routing rather than a copy of it.
|
||||
*/
|
||||
function loadHandleTerminalData(app: App, sent: string[]) {
|
||||
const marker = 'const handleTerminalData = (data) => {';
|
||||
const start = terminalSource.indexOf(marker);
|
||||
if (start < 0) throw new Error('handleTerminalData definition not found');
|
||||
const bodyStart = start + marker.length;
|
||||
const end = terminalSource.indexOf('\n };', bodyStart);
|
||||
if (end < 0) throw new Error('handleTerminalData boundary not found');
|
||||
const timers: Array<() => void> = [];
|
||||
app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
document: { activeElement: null, getElementById: vi.fn(() => null) },
|
||||
window: {
|
||||
cjkActive: false,
|
||||
CodemanTerminalInput: {
|
||||
BRACKETED_PASTE_START: '\x1b[200~',
|
||||
shouldSuppressTerminalQueryResponse: () => false,
|
||||
isTerminalFocusOrMouseReport: () => false,
|
||||
isComposerNavKey: () => false,
|
||||
},
|
||||
},
|
||||
_crashDiag: { log: vi.fn() },
|
||||
flushInput: () => {
|
||||
app._inputFlushTimeout = null;
|
||||
if (app._pendingInput && app.activeSessionId) {
|
||||
const input = app._pendingInput;
|
||||
app._pendingInput = '';
|
||||
app._sendInputAsync(app.activeSessionId, input);
|
||||
}
|
||||
},
|
||||
});
|
||||
const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
|
||||
this: App,
|
||||
data: string
|
||||
) => void;
|
||||
return { handle: (data: string) => handler.call(app, data), timers };
|
||||
}
|
||||
|
||||
describe('mobile IME commit onData routing', () => {
|
||||
function onDataApp(options: {
|
||||
localEcho: boolean;
|
||||
tagged?: boolean;
|
||||
overlayMissing?: boolean;
|
||||
addThrows?: boolean;
|
||||
appendThrows?: boolean;
|
||||
consumeThrows?: boolean;
|
||||
}) {
|
||||
const { mixin } = loadMixin();
|
||||
const sent: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: options.consumeThrows
|
||||
? vi.fn(() => {
|
||||
throw new Error('consume failed');
|
||||
})
|
||||
: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(options.tagged ?? true)
|
||||
.mockReturnValue(false),
|
||||
completeCommit: vi.fn(),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const overlay = {
|
||||
pendingText: '',
|
||||
appendText: vi.fn((data: string) => {
|
||||
if (options.appendThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
addChar: vi.fn((data: string) => {
|
||||
if (options.addThrows) throw new Error('overlay failed');
|
||||
overlay.pendingText += data;
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
overlay.pendingText = '';
|
||||
}),
|
||||
suppressBufferDetection: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
activeSessionId: 'session-a',
|
||||
_localEchoEnabled: options.localEcho,
|
||||
_localEchoOverlay: options.overlayMissing ? null : overlay,
|
||||
_echoPassthroughSessions: new Set(),
|
||||
_flushedOffsets: new Map(),
|
||||
_flushedTexts: new Map(),
|
||||
_pendingInput: '',
|
||||
_inputFlushTimeout: null,
|
||||
_lastKeystrokeTime: 0,
|
||||
_terminalOutputSeq: 5,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
|
||||
}
|
||||
|
||||
it('moves a multi-character commit into local echo and submits it only on Enter', () => {
|
||||
const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
|
||||
handle('你好');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(overlay.appendText).toHaveBeenCalledWith('你好');
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(app._mobileImeCommitOutputSeq).toBeNull();
|
||||
expect(sent).toEqual([]);
|
||||
|
||||
handle('\r');
|
||||
expect(sent).toEqual(['你好']);
|
||||
timers.shift()?.();
|
||||
expect(sent).toEqual(['你好', '\r']);
|
||||
});
|
||||
|
||||
it('moves a single-character commit into local echo through addChar', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
|
||||
handle('界');
|
||||
expect(overlay.addChar).toHaveBeenCalledWith('界');
|
||||
expect(overlay.appendText).not.toHaveBeenCalled();
|
||||
expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['appendText throws', { appendThrows: true }, '失敗'],
|
||||
['addChar throws', { addThrows: true }, '字'],
|
||||
])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
|
||||
// One code path with typed text: no send-on-throw fallback, which would
|
||||
// double-send if the overlay threw after appending.
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
|
||||
expect(() => handle(text)).toThrow('overlay failed');
|
||||
expect(sent).toEqual([]);
|
||||
// Nothing shows the text, so the preview keeps it until its fallback.
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the preview when the overlay is missing, exactly like typed text', () => {
|
||||
const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
|
||||
expect(() => handle('日本')).not.toThrow();
|
||||
expect(sent).toEqual([]);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes the commit only after the printable branch has put it in the overlay', () => {
|
||||
const { controller, overlay, handle } = onDataApp({ localEcho: true });
|
||||
controller.completeCommit.mockImplementation(() => {
|
||||
expect(overlay.pendingText).toBe('界');
|
||||
});
|
||||
handle('界');
|
||||
expect(controller.completeCommit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps an untagged paste on the existing local echo path', () => {
|
||||
const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
|
||||
handle('plain paste');
|
||||
expect(overlay.pendingText).toBe('plain paste');
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends a commit once without local echo and holds the preview until output arrives', () => {
|
||||
const { app, controller, sent, handle } = onDataApp({ localEcho: false });
|
||||
handle('日本語');
|
||||
expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
|
||||
expect(sent).toEqual(['日本語']);
|
||||
expect(controller.completeCommit).not.toHaveBeenCalled();
|
||||
expect(app._mobileImeCommitOutputSeq).toBe(5);
|
||||
});
|
||||
|
||||
it('sends the original bytes exactly once when the controller throws', () => {
|
||||
const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
|
||||
expect(() => handle('你好')).not.toThrow();
|
||||
expect(sent).toEqual(['你好']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile IME commit and authoritative terminal output', () => {
|
||||
function outputHarness(mode = 'claude') {
|
||||
const { mixin } = loadMixin();
|
||||
const parses: Array<() => void> = [];
|
||||
const written: string[] = [];
|
||||
const controller = {
|
||||
consumeTerminalData: vi.fn(() => true),
|
||||
noteAuthoritativeOutput: vi.fn(),
|
||||
};
|
||||
const app: App = Object.assign(Object.create(mixin), {
|
||||
pendingWrites: [],
|
||||
terminal: {
|
||||
rows: 24,
|
||||
write: vi.fn((data: string, callback?: () => void) => {
|
||||
written.push(data);
|
||||
if (callback) parses.push(callback);
|
||||
}),
|
||||
buffer: { active: { viewportY: 0 } },
|
||||
scrollToBottom: vi.fn(),
|
||||
},
|
||||
activeSessionId: 'session-a',
|
||||
sessions: new Map([['session-a', { mode }]]),
|
||||
isTerminalAtBottom: () => true,
|
||||
_hasRecentUserScrollUp: () => false,
|
||||
_safeYield: vi.fn(),
|
||||
_localEchoOverlay: null,
|
||||
_mobileImePreview: controller,
|
||||
});
|
||||
const flush = () => {
|
||||
app.writeFrameScheduled = false;
|
||||
app.flushPendingWrites();
|
||||
};
|
||||
const parseNext = () => parses.shift()?.();
|
||||
return { app, controller, written, flush, parseNext };
|
||||
}
|
||||
|
||||
it('clears the committed preview once output accepted after the commit is parsed', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output queued before the commit clear it, even when it parses after', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app.batchTerminalWrite('before');
|
||||
flush();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
|
||||
app.batchTerminalWrite('after');
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let a split chunk clear the commit before its remainder is written', () => {
|
||||
const { app, controller, written, flush, parseNext } = outputHarness('codex');
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('x'.repeat(40000));
|
||||
flush();
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
flush();
|
||||
parseNext();
|
||||
expect(written.join('')).toBe('x'.repeat(40000));
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not let output parsed after a session switch clear the new session preview', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
app.batchTerminalWrite('echo');
|
||||
flush();
|
||||
app.activeSessionId = 'session-b';
|
||||
parseNext();
|
||||
expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('notifies once per commit, never for later output', () => {
|
||||
const { app, controller, flush, parseNext } = outputHarness();
|
||||
app._consumeMobileImeTerminalData('你好');
|
||||
for (const chunk of ['a', 'b']) {
|
||||
app.batchTerminalWrite(chunk);
|
||||
flush();
|
||||
parseNext();
|
||||
}
|
||||
expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* The iOS IME preview controller against a REAL xterm 6 instance.
|
||||
*
|
||||
* The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
|
||||
* with a stand-in for xterm. What only real xterm proves is the event ORDER:
|
||||
* `terminal.open()` registers xterm's keydown listener in the capture phase on
|
||||
* the helper textarea, and CompositionHelper.keydown finalizes a composition
|
||||
* there and emits the commit through onData synchronously. The controller must
|
||||
* observe that keydown first (capture phase on `terminal.element`), and must
|
||||
* finalize on exactly the keys xterm does.
|
||||
*
|
||||
* No server: a blank page loads the vendored xterm bundle and the controller.
|
||||
* Browser-driven, so it is excluded from `npm run test:ci` like the other
|
||||
* Playwright suites. Run locally:
|
||||
* npm run test:browser -- test/mobile-ime-preview.browser.test.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { build } from 'esbuild';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
type Step =
|
||||
| ['start']
|
||||
| ['update', string]
|
||||
| ['end', string]
|
||||
| ['key', number, string, boolean]
|
||||
| ['wait', number]
|
||||
| ['consume', string];
|
||||
|
||||
describe('mobile IME preview with real xterm', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.setContent('<div id="t" style="width:600px;height:300px"></div>');
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
async function drive(steps: Step[]) {
|
||||
return page.evaluate(async (steps: Step[]) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal();
|
||||
term.open(host);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
const renders: Array<{ text: string; phase: string }> = [];
|
||||
const onData: Array<{ data: string; consumed: boolean }> = [];
|
||||
const controller = w.MobileImePreview.create({
|
||||
textarea,
|
||||
keydownTarget: term.element,
|
||||
render: (r: { text: string; phase: string }) => renders.push(r),
|
||||
clear: () => {},
|
||||
});
|
||||
term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
|
||||
textarea.focus();
|
||||
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
for (const step of steps) {
|
||||
if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
if (step[0] === 'update') {
|
||||
textarea.value = step[1];
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
|
||||
}
|
||||
if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
|
||||
if (step[0] === 'key') {
|
||||
const [, keyCode, key, isComposing] = step;
|
||||
const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'keyCode', { get: () => keyCode });
|
||||
textarea.dispatchEvent(event);
|
||||
}
|
||||
if (step[0] === 'wait') await tick(step[1]);
|
||||
if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
|
||||
}
|
||||
await tick(20);
|
||||
const { composing, awaitingCommit, committed, latest } = controller.state;
|
||||
const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
|
||||
controller.destroy();
|
||||
term.dispose();
|
||||
return result;
|
||||
}, steps);
|
||||
}
|
||||
|
||||
it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
|
||||
// compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
|
||||
const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
|
||||
expect(result.onData).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
|
||||
expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
|
||||
});
|
||||
|
||||
it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'か'],
|
||||
['wait', 10],
|
||||
['key', 229, 'k', false],
|
||||
['update', 'かな'],
|
||||
]);
|
||||
expect(result.onData).toEqual([]);
|
||||
expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
|
||||
expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
|
||||
});
|
||||
|
||||
it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
|
||||
const result = await drive([
|
||||
['start'],
|
||||
['update', 'abc'],
|
||||
['update', ''],
|
||||
['end', ''],
|
||||
['wait', 2100],
|
||||
['consume', 'pasted'],
|
||||
]);
|
||||
expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
|
||||
expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The preview with local echo ON, the default for Claude sessions on phones.
|
||||
* Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
|
||||
* `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
|
||||
* which is where the helper span sits, stays at the prompt start: under the
|
||||
* overlay's own opaque text. So a composition that follows text already in the
|
||||
* overlay must be drawn by the overlay itself, after that text.
|
||||
*
|
||||
* Loads the real pieces: xterm 6, the overlay bundled from its package source
|
||||
* exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
|
||||
* alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
|
||||
* `_initMobileImePreview` on a bare CodemanApp prototype.
|
||||
*/
|
||||
describe('mobile IME preview over the local echo overlay', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bundled = await build({
|
||||
entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'XtermZerolagInput',
|
||||
write: false,
|
||||
logLevel: 'silent',
|
||||
});
|
||||
const overlayBundle =
|
||||
bundled.outputFiles[0].text +
|
||||
'\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
|
||||
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
|
||||
'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
|
||||
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
|
||||
await page.setContent(
|
||||
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
|
||||
);
|
||||
await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
|
||||
await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
|
||||
await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
|
||||
await page.addScriptTag({ content: overlayBundle });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
|
||||
await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
|
||||
await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* Types `pending` into the overlay (as the printable/paste branch does), then
|
||||
* composes `composing` and reports what is PAINTED at the cell right after
|
||||
* the pending text and at the PTY cursor. Painted = topmost by hit-testing
|
||||
* with pointer-events forced on, since the overlay and the preview are
|
||||
* pointer-events:none.
|
||||
*/
|
||||
async function composeAfter(pending: string, composing: string, commit: boolean) {
|
||||
return page.evaluate(
|
||||
async ({ pending, composing, commit }) => {
|
||||
const w = window as any;
|
||||
const host = document.getElementById('t') as HTMLElement;
|
||||
host.innerHTML = '';
|
||||
const term = new w.Terminal({
|
||||
cols: 40,
|
||||
rows: 8,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
allowProposedApi: true,
|
||||
});
|
||||
term.open(host);
|
||||
await new Promise<void>((r) => term.write('\u276f ', () => r()));
|
||||
const app = new w.CodemanApp();
|
||||
app.terminal = term;
|
||||
app._localEchoEnabled = true;
|
||||
app._localEchoOverlay = new w.LocalEchoOverlay(term);
|
||||
w.MobileImePreview.isIosWebKitTouch = () => true;
|
||||
app._initMobileImePreview();
|
||||
|
||||
// The helper textarea and span follow the PTY cursor (col 2, row 0), as
|
||||
// _syncMobileHelperTextareaToCursor places them.
|
||||
const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
|
||||
const dims = term._core._renderService.dimensions.css.cell;
|
||||
term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
|
||||
term.element.style.setProperty('--xterm-helper-top', '0px');
|
||||
|
||||
if (pending) app._localEchoOverlay.appendText(pending);
|
||||
const textarea = term.textarea as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
|
||||
textarea.value = composing;
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
|
||||
const force = document.createElement('style');
|
||||
force.textContent = '.xterm * { pointer-events: auto !important; }';
|
||||
document.head.appendChild(force);
|
||||
const rect = screen.getBoundingClientRect();
|
||||
const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
|
||||
const paintedAt = (col: number) => {
|
||||
const el = document.elementFromPoint(
|
||||
rect.left + (col + 0.5) * dims.width,
|
||||
rect.top + 0.5 * dims.height
|
||||
) as HTMLElement | null;
|
||||
return {
|
||||
text: el?.textContent ?? null,
|
||||
composition: !!el?.closest?.('[data-zerolag-composition]'),
|
||||
preview: !!el?.closest?.('.codeman-ime-preview'),
|
||||
};
|
||||
};
|
||||
const afterPending = paintedAt(2 + widthOf(pending));
|
||||
force.remove();
|
||||
|
||||
let afterCommit = null;
|
||||
if (commit) {
|
||||
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
|
||||
// What the printable/paste branch of terminal-ui.js's onData does.
|
||||
if (app._consumeMobileImeTerminalData(composing)) {
|
||||
app._localEchoOverlay.appendText(composing);
|
||||
app._transferMobileImeCommitToLocalEcho();
|
||||
}
|
||||
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
|
||||
afterCommit = {
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
|
||||
overlayText: app._localEchoOverlay._overlay?.textContent,
|
||||
};
|
||||
}
|
||||
const result = {
|
||||
afterPending,
|
||||
pendingText: app._localEchoOverlay.pendingText,
|
||||
afterCommit,
|
||||
};
|
||||
app._destroyMobileImePreview();
|
||||
app._localEchoOverlay.dispose();
|
||||
term.dispose();
|
||||
return result;
|
||||
},
|
||||
{ pending, composing, commit }
|
||||
);
|
||||
}
|
||||
|
||||
it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
|
||||
const result = await composeAfter('', '今日は', false);
|
||||
expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
|
||||
expect(result.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('a second composition is painted after the text already in the overlay, not under it', async () => {
|
||||
const result = await composeAfter('今日は', '天気', false);
|
||||
expect(result.afterPending.text).toBe('天');
|
||||
expect(result.afterPending.composition).toBe(true);
|
||||
// Provisional text is never taken into the overlay's pending (unsent) text.
|
||||
expect(result.pendingText).toBe('今日は');
|
||||
});
|
||||
|
||||
it('the commit lands once in the overlay and the composition tail is gone', async () => {
|
||||
const result = await composeAfter('今日は', '天気', true);
|
||||
expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
type Listener = (event: Record<string, unknown>) => void;
|
||||
type ListenerOptions = boolean | { capture?: boolean };
|
||||
type RegisteredListener = { listener: Listener; capture: boolean };
|
||||
|
||||
/**
|
||||
* A DOM node with just enough event dispatch to reproduce listener ORDER: an
|
||||
* ancestor's capture listeners, then the target's capture listeners, then the
|
||||
* target's bubble listeners (at-target capture-first, as in Chromium 89+ and
|
||||
* WebKit), then the ancestor's bubble listeners.
|
||||
*/
|
||||
class FakeNode {
|
||||
parent: FakeNode | null = null;
|
||||
protected listeners = new Map<string, RegisteredListener[]>();
|
||||
|
||||
addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push({ listener, capture: options === true || options?.capture === true });
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
|
||||
const capture = options === true || options?.capture === true;
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
const index = listeners.findIndex(
|
||||
(registered) => registered.listener === listener && registered.capture === capture
|
||||
);
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
}
|
||||
|
||||
run(type: string, capture: boolean, event: Record<string, unknown>) {
|
||||
for (const registered of [...(this.listeners.get(type) ?? [])]) {
|
||||
if (registered.capture === capture) registered.listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const full = { type, target: this, ...event };
|
||||
const ancestors: FakeNode[] = [];
|
||||
for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
|
||||
for (const ancestor of ancestors) ancestor.run(type, true, full);
|
||||
this.run(type, true, full);
|
||||
this.run(type, false, full);
|
||||
for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
|
||||
}
|
||||
|
||||
listenerCount() {
|
||||
return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTextarea extends FakeNode {
|
||||
value = 'unchanged';
|
||||
}
|
||||
|
||||
type Scheduled = { id: number; callback: () => void; delay?: number };
|
||||
|
||||
function harness(
|
||||
overrides: Record<string, unknown> = {},
|
||||
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | undefined) => void
|
||||
) {
|
||||
const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
|
||||
const context = vm.createContext({ navigator: {} });
|
||||
vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
|
||||
const api = vm.runInContext('MobileImePreview', context);
|
||||
// The terminal element: an ancestor of the helper textarea, like xterm's
|
||||
// `.xterm` root is of `.xterm-helper-textarea`.
|
||||
const element = new FakeNode();
|
||||
const textarea = new FakeTextarea();
|
||||
textarea.parent = element;
|
||||
const frames: Scheduled[] = [];
|
||||
const timers: Scheduled[] = [];
|
||||
let nextId = 1;
|
||||
const render = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const scheduleFrame = vi.fn((callback: () => void) => {
|
||||
const id = nextId++;
|
||||
frames.push({ id, callback });
|
||||
return id;
|
||||
});
|
||||
const cancelFrame = vi.fn((id: number) => {
|
||||
const index = frames.findIndex((frame) => frame.id === id);
|
||||
if (index >= 0) frames.splice(index, 1);
|
||||
});
|
||||
const setTimer = vi.fn((callback: () => void, delay: number) => {
|
||||
const id = nextId++;
|
||||
timers.push({ id, callback, delay });
|
||||
return id;
|
||||
});
|
||||
const clearTimer = vi.fn((id: number) => {
|
||||
const index = timers.findIndex((timer) => timer.id === id);
|
||||
if (index >= 0) timers.splice(index, 1);
|
||||
});
|
||||
let controller: Record<string, unknown> | undefined;
|
||||
beforeCreate?.(textarea, () => controller);
|
||||
controller = api.create({
|
||||
textarea,
|
||||
keydownTarget: element,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
...overrides,
|
||||
});
|
||||
const flushFrame = () => frames.shift()?.callback();
|
||||
const flushTimer = () => timers.shift()?.callback();
|
||||
|
||||
return {
|
||||
api,
|
||||
element,
|
||||
textarea,
|
||||
frames,
|
||||
timers,
|
||||
render,
|
||||
clear,
|
||||
onCommit,
|
||||
scheduleFrame,
|
||||
cancelFrame,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
controller,
|
||||
flushFrame,
|
||||
flushTimer,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MobileImePreview', () => {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
test('collapses 500 composition updates into one latest-state frame', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
|
||||
|
||||
expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.textarea.value).toBe('unchanged');
|
||||
});
|
||||
|
||||
test('replaces provisional text for replacement, backspace, and composing input', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
|
||||
h.textarea.dispatch('compositionupdate', { data: 'xy' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
|
||||
|
||||
h.textarea.dispatch('input', { data: '', isComposing: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('caps the preview without changing the terminal handoff value', () => {
|
||||
const h = harness();
|
||||
const value = '界'.repeat(2050);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: value });
|
||||
h.flushFrame();
|
||||
expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
|
||||
h.textarea.dispatch('compositionend', { data: value });
|
||||
expect(h.controller.consumeTerminalData(value)).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
test('hands off only the first safe xterm onData value after finalization', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '日本語' });
|
||||
h.textarea.dispatch('compositionend', { data: '日本語' });
|
||||
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(true);
|
||||
expect(h.controller.consumeTerminalData('日本語')).toBe(false);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
|
||||
expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
|
||||
});
|
||||
|
||||
test('defers finalization rendering and updates the queued frame phase to committed', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
expect(h.frames).toHaveLength(1);
|
||||
|
||||
expect(h.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(h.frames).toHaveLength(1);
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenCalledOnce();
|
||||
expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
|
||||
});
|
||||
|
||||
test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'teh' });
|
||||
h.textarea.dispatch('compositionend');
|
||||
|
||||
expect(h.controller.consumeTerminalData('the')).toBe(true);
|
||||
expect(h.onCommit).toHaveBeenCalledOnce();
|
||||
expect(h.onCommit).toHaveBeenCalledWith('the');
|
||||
expect(h.controller.consumeTerminalData('teh')).toBe(false);
|
||||
});
|
||||
|
||||
test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
|
||||
'rejects non-printable or multiline terminal data %j without consuming the pending value',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: rejected });
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['line\u2028break', 'line\u2029break'])(
|
||||
'rejects Unicode line separator terminal data %j without consuming the finalization fence',
|
||||
(rejected) => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend');
|
||||
expect(h.controller.consumeTerminalData(rejected)).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('safe')).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('keydown can finalize composition before a late compositionend', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.consumeTerminalData('確定')).toBe(true);
|
||||
h.textarea.dispatch('compositionend', { data: 'stale' });
|
||||
expect(h.controller.consumeTerminalData('stale')).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
|
||||
* listener on the helper textarea (CoreBrowserTerminal.ts:379), and
|
||||
* CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
|
||||
* composition there, emitting the commit through onData synchronously, for
|
||||
* every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
|
||||
* controller is created, exactly as terminal.open() precedes
|
||||
* _initMobileImePreview().
|
||||
*/
|
||||
function withXtermStandIn() {
|
||||
const emitted: Array<{ data: string; consumed: boolean }> = [];
|
||||
let composing = false;
|
||||
let composition = '';
|
||||
const h = harness({}, (textarea, getController) => {
|
||||
const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
|
||||
textarea.addEventListener('compositionstart', () => {
|
||||
composing = true;
|
||||
composition = '';
|
||||
});
|
||||
textarea.addEventListener('compositionupdate', (event) => {
|
||||
composition = String(event.data ?? '');
|
||||
});
|
||||
textarea.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
|
||||
composing = false;
|
||||
emit(composition);
|
||||
}
|
||||
if (event.keyCode === 13) emit('\r');
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
return { ...h, emitted, isXtermComposing: () => composing };
|
||||
}
|
||||
|
||||
test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '確定' });
|
||||
h.flushFrame();
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: '確定', consumed: true },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).toHaveBeenCalledWith('確定');
|
||||
expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
|
||||
|
||||
// The next unrelated keystroke is ordinary input, not an IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
// keyCode 229 with isComposing:false and a real key identity: xterm keeps
|
||||
// composing, so the controller must too.
|
||||
['the IME composition character', 'k', 229],
|
||||
['CapsLock', 'CapsLock', 20],
|
||||
['Shift', 'Shift', 16],
|
||||
['Control', 'Control', 17],
|
||||
['Alt', 'Alt', 18],
|
||||
])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
|
||||
const h = withXtermStandIn();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'か' });
|
||||
h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
|
||||
expect(h.isXtermComposing()).toBe(true);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
|
||||
// The preview follows the composition instead of freezing on the old value.
|
||||
h.textarea.dispatch('compositionupdate', { data: 'かな' });
|
||||
h.flushFrame();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
|
||||
expect(h.emitted).toEqual([]);
|
||||
});
|
||||
|
||||
test('ignores keydowns that did not target the helper textarea', () => {
|
||||
const h = harness();
|
||||
const sibling = new FakeNode();
|
||||
sibling.parent = h.element;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: '漢字' });
|
||||
sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
|
||||
});
|
||||
|
||||
test('destroy stops the controller observing keydown on the terminal element', () => {
|
||||
const h = withXtermStandIn();
|
||||
h.controller.destroy();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'later' });
|
||||
h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
|
||||
expect(h.emitted).toEqual([
|
||||
{ data: 'later', consumed: false },
|
||||
{ data: '\r', consumed: false },
|
||||
]);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
describe('a commit that never reaches onData', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function realTimerHarness() {
|
||||
return harness({
|
||||
setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
clearTimer: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
|
||||
});
|
||||
}
|
||||
|
||||
test.each([
|
||||
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
|
||||
[
|
||||
'a finalizing keydown',
|
||||
(h: ReturnType<typeof harness>) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
|
||||
],
|
||||
])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'deleted' });
|
||||
finalize(h);
|
||||
h.clear.mockClear();
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
|
||||
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(h.controller.state.awaitingCommit).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
// The next unrelated keystroke or paste is not adopted as the IME commit.
|
||||
expect(h.controller.consumeTerminalData('x')).toBe(false);
|
||||
expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
|
||||
expect(h.onCommit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a commit that arrives in time replaces the wait bound with the committed one', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '日本' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
expect(h.controller.consumeTerminalData('日本')).toBe(true);
|
||||
// The wait bound would have fired at 2000 ms; the committed bound runs
|
||||
// a full 2 s from the commit instead.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(true);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('a new composition cancels the previous wait bound', () => {
|
||||
const h = realTimerHarness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionend', { data: '' });
|
||||
vi.advanceTimersByTime(1500);
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'next' });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
|
||||
});
|
||||
});
|
||||
|
||||
test('generation fences stale frames and timers', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'old' });
|
||||
const staleFrame = h.frames[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
staleFrame();
|
||||
expect(h.render).not.toHaveBeenCalled();
|
||||
|
||||
h.textarea.dispatch('compositionend', { data: 'first' });
|
||||
h.controller.consumeTerminalData('first');
|
||||
const staleTimer = h.timers[0].callback;
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'new' });
|
||||
h.flushFrame();
|
||||
staleTimer();
|
||||
expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
|
||||
});
|
||||
|
||||
test('predicted completion clears immediately while fallback waits for output or TTL', () => {
|
||||
const predicted = harness();
|
||||
predicted.textarea.dispatch('compositionstart');
|
||||
predicted.clear.mockClear();
|
||||
predicted.textarea.dispatch('compositionend', { data: 'one' });
|
||||
predicted.controller.consumeTerminalData('one');
|
||||
expect(predicted.frames).toHaveLength(1);
|
||||
predicted.controller.completeCommit({ predicted: true });
|
||||
expect(predicted.clear).toHaveBeenCalledOnce();
|
||||
expect(predicted.frames).toHaveLength(0);
|
||||
expect(predicted.timers).toHaveLength(0);
|
||||
expect(predicted.controller.state.latest).toBe('');
|
||||
expect(predicted.controller.state.committed).toBe(false);
|
||||
|
||||
const fallback = harness();
|
||||
fallback.textarea.dispatch('compositionstart');
|
||||
fallback.clear.mockClear();
|
||||
fallback.textarea.dispatch('compositionend', { data: 'two' });
|
||||
fallback.controller.consumeTerminalData('two');
|
||||
fallback.controller.completeCommit({ predicted: false });
|
||||
expect(fallback.clear).not.toHaveBeenCalled();
|
||||
fallback.controller.noteAuthoritativeOutput();
|
||||
expect(fallback.clear).toHaveBeenCalledOnce();
|
||||
expect(fallback.frames).toHaveLength(0);
|
||||
expect(fallback.timers).toHaveLength(0);
|
||||
expect(fallback.controller.state.latest).toBe('');
|
||||
expect(fallback.controller.state.committed).toBe(false);
|
||||
|
||||
const ttl = harness();
|
||||
ttl.textarea.dispatch('compositionstart');
|
||||
ttl.clear.mockClear();
|
||||
ttl.textarea.dispatch('compositionend', { data: 'three' });
|
||||
ttl.controller.consumeTerminalData('three');
|
||||
ttl.controller.completeCommit({ predicted: false });
|
||||
ttl.flushTimer();
|
||||
expect(ttl.clear).toHaveBeenCalledOnce();
|
||||
expect(ttl.frames).toHaveLength(0);
|
||||
expect(ttl.timers).toHaveLength(0);
|
||||
expect(ttl.controller.state.latest).toBe('');
|
||||
expect(ttl.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
|
||||
let resetController: { reset(): void };
|
||||
const reset = harness({ onCommit: () => resetController.reset() });
|
||||
resetController = reset.controller;
|
||||
reset.textarea.dispatch('compositionstart');
|
||||
reset.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
reset.textarea.dispatch('compositionend');
|
||||
expect(reset.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(reset.frames).toHaveLength(0);
|
||||
expect(reset.timers).toHaveLength(0);
|
||||
expect(reset.controller.state.latest).toBe('');
|
||||
|
||||
let destroyController: { destroy(): void };
|
||||
const destroy = harness({ onCommit: () => destroyController.destroy() });
|
||||
destroyController = destroy.controller;
|
||||
destroy.textarea.dispatch('compositionstart');
|
||||
destroy.textarea.dispatch('compositionend');
|
||||
expect(destroy.controller.consumeTerminalData('final')).toBe(true);
|
||||
expect(destroy.textarea.listenerCount()).toBe(0);
|
||||
expect(destroy.element.listenerCount()).toBe(0);
|
||||
expect(destroy.frames).toHaveLength(0);
|
||||
expect(destroy.timers).toHaveLength(0);
|
||||
expect(destroy.controller.state.latest).toBe('');
|
||||
});
|
||||
|
||||
test('contains re-entrant render and clear callbacks', () => {
|
||||
let renderController: { reset(): void };
|
||||
const render = harness({ render: () => renderController.reset() });
|
||||
renderController = render.controller;
|
||||
render.textarea.dispatch('compositionstart');
|
||||
render.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
expect(() => render.flushFrame()).not.toThrow();
|
||||
expect(render.frames).toHaveLength(0);
|
||||
expect(render.timers).toHaveLength(0);
|
||||
expect(render.controller.state.latest).toBe('');
|
||||
|
||||
let clearController: { destroy(): void };
|
||||
const clear = harness({ clear: () => clearController?.destroy() });
|
||||
clearController = clear.controller;
|
||||
expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
|
||||
expect(clear.textarea.listenerCount()).toBe(0);
|
||||
expect(clear.element.listenerCount()).toBe(0);
|
||||
expect(clear.frames).toHaveLength(0);
|
||||
expect(clear.timers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('fails open when frame or timer schedulers throw', () => {
|
||||
const frame = harness({
|
||||
scheduleFrame: () => {
|
||||
throw new Error('frame scheduler');
|
||||
},
|
||||
});
|
||||
frame.textarea.dispatch('compositionstart');
|
||||
expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
|
||||
expect(frame.controller.state.framePending).toBe(false);
|
||||
|
||||
let lateFrame: (() => void) | undefined;
|
||||
const timer = harness({
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
lateFrame = callback;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame: () => {},
|
||||
setTimer: () => {
|
||||
throw new Error('timer scheduler');
|
||||
},
|
||||
});
|
||||
timer.textarea.dispatch('compositionstart');
|
||||
timer.clear.mockClear();
|
||||
timer.textarea.dispatch('compositionend');
|
||||
expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(timer.controller.state.committed).toBe(false);
|
||||
expect(timer.controller.state.latest).toBe('');
|
||||
expect(timer.controller.state.framePending).toBe(false);
|
||||
expect(timer.controller.state.timerPending).toBe(false);
|
||||
expect(timer.clear).toHaveBeenCalledOnce();
|
||||
lateFrame?.();
|
||||
expect(timer.render).not.toHaveBeenCalled();
|
||||
|
||||
const tokenlessTimer = harness({ setTimer: () => undefined });
|
||||
tokenlessTimer.textarea.dispatch('compositionstart');
|
||||
tokenlessTimer.clear.mockClear();
|
||||
tokenlessTimer.textarea.dispatch('compositionend');
|
||||
expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
|
||||
expect(tokenlessTimer.controller.state.committed).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.latest).toBe('');
|
||||
expect(tokenlessTimer.controller.state.framePending).toBe(false);
|
||||
expect(tokenlessTimer.controller.state.timerPending).toBe(false);
|
||||
expect(tokenlessTimer.frames).toHaveLength(0);
|
||||
expect(tokenlessTimer.timers).toHaveLength(0);
|
||||
expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
|
||||
|
||||
const cancellation = harness({
|
||||
cancelFrame: () => {
|
||||
throw new Error('frame cancellation');
|
||||
},
|
||||
clearTimer: () => {
|
||||
throw new Error('timer cancellation');
|
||||
},
|
||||
});
|
||||
cancellation.textarea.dispatch('compositionstart');
|
||||
cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
|
||||
cancellation.textarea.dispatch('compositionend');
|
||||
cancellation.controller.consumeTerminalData('final');
|
||||
expect(() => cancellation.controller.reset()).not.toThrow();
|
||||
expect(cancellation.controller.state.framePending).toBe(false);
|
||||
expect(cancellation.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('authoritative output never clears active provisional composition', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'active' });
|
||||
h.flushFrame();
|
||||
h.controller.noteAuthoritativeOutput();
|
||||
expect(h.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('blur and reset cancel scheduled work and clear visual state', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
h.textarea.dispatch('blur');
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.clear.mockClear();
|
||||
h.textarea.dispatch('compositionupdate', { data: 'again' });
|
||||
h.controller.reset();
|
||||
expect(h.frames).toHaveLength(0);
|
||||
expect(h.clear).toHaveBeenCalledOnce();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.committed).toBe(false);
|
||||
});
|
||||
|
||||
test('destroy is idempotent and removes listeners and scheduled work', () => {
|
||||
const h = harness();
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'pending' });
|
||||
expect(h.textarea.listenerCount()).toBe(5);
|
||||
expect(h.element.listenerCount()).toBe(1);
|
||||
h.controller.destroy();
|
||||
h.controller.destroy();
|
||||
expect(h.textarea.listenerCount()).toBe(0);
|
||||
expect(h.element.listenerCount()).toBe(0);
|
||||
expect(h.frames).toHaveLength(0);
|
||||
h.textarea.dispatch('compositionupdate', { data: 'ignored' });
|
||||
expect(h.scheduleFrame).toHaveBeenCalledOnce();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
expect(h.controller.state.latest).toBe('');
|
||||
expect(h.controller.state.framePending).toBe(false);
|
||||
expect(h.controller.state.timerPending).toBe(false);
|
||||
});
|
||||
|
||||
test('contains render, clear, and commit callback exceptions', () => {
|
||||
const h = harness({
|
||||
render: vi.fn(() => {
|
||||
throw new Error('render');
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
throw new Error('clear');
|
||||
}),
|
||||
onCommit: vi.fn(() => {
|
||||
throw new Error('commit');
|
||||
}),
|
||||
});
|
||||
h.textarea.dispatch('compositionstart');
|
||||
h.textarea.dispatch('compositionupdate', { data: 'safe' });
|
||||
expect(() => h.flushFrame()).not.toThrow();
|
||||
h.textarea.dispatch('compositionend', { data: 'safe' });
|
||||
expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
|
||||
expect(() => h.controller.reset()).not.toThrow();
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
|
||||
[{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
|
||||
[{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
|
||||
])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
|
||||
expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user