mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
Merge-time fixes for the three findings of the third review round of #499. - minor: a composition on an empty prompt did not follow the prompt after output or a resize. The post-write re-place in flushPendingWrites and the resize observer both ran rerender() only when hasPending was true, and hasPending deliberately excludes the composition, so the first word of a prompt (an overlay holding only a composition) stayed on the old row over whatever output moved there. Both sites now call rerender() unconditionally; it already returns early when there is nothing to draw, so nothing changes without a composition. New browser case drives the real batchTerminalWrite/flushPendingWrites path against real xterm 6 and the overlay built from source, moves the prompt from row 0 to row 3 and checks the overlay follows (it fails on the old guard, overlay left on row 0), with a parity case for pending text. The structure test pins the post-write site through vm and the resize site, which is a closure inside initTerminal(), by source. - nit: removeChar() dropped the composition but did not repaint on its false path, leaving a composition-only overlay on screen showing text the addon no longer held. It now hides the overlay there when a composition was dropped. Package tests cover that path and the flushed path repainting without the tail. - nit: the package README did not document setComposition() or the composition getter and described hasPending as "any content". Added both to the API tables plus a short IME composition section, reworded hasPending (pending or flushed text, excludes the composition), and made the quick start re-render unconditionally instead of teaching the hasPending guard. The hasPending JSDoc says the same. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
723 lines
29 KiB
TypeScript
723 lines
29 KiB
TypeScript
/**
|
|
* @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('re-places an overlay that holds only a composition after output (hasPending is false there)', () => {
|
|
const { app, flush } = outputHarness();
|
|
const overlay = { hasPending: false, pendingText: '', composition: '今日', rerender: vi.fn() };
|
|
app._localEchoOverlay = overlay;
|
|
app.batchTerminalWrite('output that moves the prompt');
|
|
flush();
|
|
expect(overlay.rerender).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('re-places it after a resize too: no rerender() site is gated on hasPending', () => {
|
|
// The resize observer is a closure inside initTerminal(), so it is pinned
|
|
// by source; the post-write follow runs against real xterm in
|
|
// test/mobile-ime-preview.browser.test.ts.
|
|
expect(terminalSource).toMatch(
|
|
/this\.updateConnectionLines\(\);\s*(?:\/\/[^\n]*\n\s*)*this\._localEchoOverlay\?\.rerender\(\);/
|
|
);
|
|
expect(terminalSource).not.toMatch(/hasPending\)\s*\{?\s*this\._localEchoOverlay\.rerender\(\)/);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|