Files
Codeman/test/terminal-keycode229-recovery.test.ts
T
Codeman maintainer 02b0e27898 fix: merge-time follow-ups for #400, #401, #362 and #388
Each item is from the pre-merge review of the PR it names, applied on master
rather than by pushing to a contributor branch.

#400 (response viewer, shenlvkang-collab)
- The brief view opened at `scrollTop = 0`, right when it was a single card
  holding the last row. Now that it renders the whole turn, the top is the
  turn's first narration line and the answer can be screens below it, while
  loadFullContext already scrolls to the bottom of the same turn. A multi-row
  turn now opens at its newest text; a single card still opens at the top.

#401 (loopback links as web tabs, shenlvkang-collab)
- Drop `*.localhost` from the auto-route set. Every other member is an address
  literal that can only mean this box; a `*.localhost` DNS name is not one, and
  a resolver with a search domain retries `evil.localhost` as
  `evil.localhost.<search domain>`. The link source is agent-written terminal
  output, so that set is the whole confinement on a tap that makes Codeman
  fetch a URL server-side and persist it. The page-side test stays broader
  (`isOnBoxHostname`), where a false positive only declines to proxy.
- A link to the origin root navigated nothing: the path was flattened to '',
  which openWebview reads as "no deep link", leaving an open frame where it was.
- `this.webviews` being set does not mean it is loaded. initWebviews() assigns a
  truthy empty map and only then awaits the list, so a tap during page load
  found nothing to reuse and POSTed a duplicate record. Join the in-flight
  refresh instead.
- One dashboard per dev server rather than per host spelling, which is what the
  method's own comment already promised.
- Toast on the auto-create: it writes webviews.json, broadcasts over SSE and
  adds a Run-dropdown row on every signed-in device, with a new tab as its only
  previous signal.

#362 (remote omp continuation, timkjr)
- Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render
  would hand deepseek a locally-resolved --profile and bypass claude's own
  overlay. A registry-declared switch is the follow-up if a third mode needs it.
- Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is
  hand-formatted and outside `npm run format`), keeping only the two new
  sections.
- Correct three stale passages: architecture-invariants' `exec claude
  --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp
  now have their own arms, and the claude pane's PID is the login shell), and
  omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp.
- Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling
  guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first
  idle turn.

#388 (keyCode 229 recovery, aakhter)
- Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and
  isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies
  xterm answers during Ink redraws and its SGR mouse and focus reports; any of
  those landing between the keydown and the candidate's resolution was read as
  "xterm spoke for this keystroke", standing the recovery down and leaving the
  character dropped, worst on a busy agent pane. Reached through
  window.CodemanTerminalInput: the predicates live in a module IIFE that closes
  long before this call site, so bare references would throw into the
  surrounding try/catch and stop the notify from ever running.

Every fix has a test that fails without it (verified by reverting each).
Full gate green on the combined tree: 358 files, 6849 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:15:41 +02:00

357 lines
13 KiB
TypeScript

/**
* Orphaned-input recovery for xterm's helper textarea (PR #388 / COD-27).
*
* xterm's CoreBrowserTerminal._inputEvent only forwards an `insertText` input
* event when `(!ev.composed || !this._keyDownSeen)`. Chrome-on-Android's soft
* keyboard produces `composed: true` input events preceded by a keydown, so
* that guard is false and the committed character is silently dropped.
*
* The controller under test forwards the event's own `data` when — and only
* when — xterm produced no canonical data for that keystroke. These tests
* drive it with synthetic events and an injected timer; no browser is needed.
*/
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type Listener = (event: Record<string, unknown>) => void;
function makeTextarea() {
const listeners = new Map<string, Set<Listener>>();
const registrations: Array<{ type: string; capture: unknown }> = [];
return {
addEventListener(type: string, listener: Listener, capture?: unknown) {
const bucket = listeners.get(type) ?? new Set<Listener>();
bucket.add(listener);
listeners.set(type, bucket);
registrations.push({ type, capture });
},
removeEventListener(type: string, listener: Listener) {
listeners.get(type)?.delete(listener);
},
fire(type: string, event: Record<string, unknown> = {}) {
for (const listener of [...(listeners.get(type) ?? [])]) listener({ type, ...event });
},
listenerCount() {
return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0);
},
registrations() {
return [...registrations];
},
};
}
/** A committed-text `input` event of the shape Chrome-on-Android delivers. */
function inputEvent(data: string, overrides: Record<string, unknown> = {}) {
return { data, inputType: 'insertText', isComposing: false, ...overrides };
}
function harness({ screenReader = false } = {}) {
const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
const exposed: Record<string, any> = {};
vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
const textarea = makeTextarea();
const emitted: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
const controller = exposed.CodemanKeyCode229Recovery.create({
textarea,
emitRecovered: (data: string) => emitted.push(data),
isScreenReaderMode: () => screenReader,
setTimer: (callback: () => void) => {
const id = ++timerId;
timers.set(id, callback);
return id;
},
clearTimer: (id: number) => timers.delete(id),
});
return {
controller,
emitted,
textarea,
/** A keydown that carries NO usable key identity, exactly like GBoard's. */
keydown(overrides: Record<string, unknown> = {}) {
controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229, ...overrides });
},
input(data: string, overrides: Record<string, unknown> = {}) {
textarea.fire('input', inputEvent(data, overrides));
},
flushTimers() {
for (const [id, callback] of [...timers]) {
timers.delete(id);
callback();
}
},
pendingTimers: () => timers.size,
};
}
/** terminal-ui.js's exported predicates, loaded the same way as in test/mobile-shell-keyboard.test.ts. */
function loadTerminalInput() {
const source = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
const win: Record<string, any> = {
addEventListener() {},
matchMedia: () => ({ matches: false, addEventListener() {} }),
};
const sandbox: Record<string, any> = {
window: win,
globalThis: win,
document: { addEventListener() {} },
CodemanApp: class {},
};
win.CodemanApp = sandbox.CodemanApp;
vm.runInNewContext(source, sandbox, { filename: 'terminal-ui.js' });
return win.CodemanTerminalInput as {
shouldSuppressTerminalQueryResponse(data: string): boolean;
isTerminalFocusOrMouseReport(data: string): boolean;
};
}
describe('orphaned terminal input recovery', () => {
it('forwards the committed text when xterm stayed silent', () => {
const h = harness();
h.keydown();
h.input('x');
expect(h.emitted).toEqual([]);
h.flushTimers();
expect(h.emitted).toEqual(['x']);
});
it('forwards nothing when xterm emitted canonical data after the keydown', () => {
// The "xterm handled it" case is decided by the COUNTER, never by assuming
// the input event does not reach us. On capture it always does (xterm's
// cancel() only stops later BUBBLE listeners), so this test dispatches the
// real input event AND has xterm emit canonical data for that keystroke.
const h = harness();
h.keydown();
h.input('x');
h.controller.notifyCanonicalData();
h.flushTimers();
expect(h.emitted).toEqual([]);
});
it('registers the input listener in the CAPTURE phase', () => {
// Measured in jsdom and headless chromium: a capture-phase listener on the
// TARGET calling stopPropagation() (which is what xterm's cancel() does in
// the branch where it handled the input) stops later BUBBLE listeners on
// that same target, because the target is visited twice in the event path.
//
// capture-then-BUBBLE, stopPropagation: ours NEVER fires
// capture-then-CAPTURE, stopPropagation: ours still fires
//
// So this must not be "tidied" to bubble: on bubble we would silently stop
// seeing exactly the events xterm handled, and whether we saw them at all
// would depend on xterm's `options.cancelEvents`, which Codeman never sets.
const h = harness();
const input = h.textarea.registrations().filter((entry) => entry.type === 'input');
expect(input).toHaveLength(1);
expect(input[0].capture).toBe(true);
for (const entry of h.textarea.registrations()) expect(entry.capture).toBe(true);
});
it('forwards nothing on the keypress path, where canonical data precedes the input event', () => {
// xterm's _keyPress calls triggerDataEvent() and sets _keyPressHandled
// BEFORE the input event fires. The "did xterm speak?" snapshot therefore
// has to be taken at keydown; taken at input time it would already include
// this emission and the character would be delivered twice.
const h = harness();
h.keydown();
h.controller.notifyCanonicalData();
h.input('x');
h.flushTimers();
expect(h.emitted).toEqual([]);
});
it('does not let a stale candidate swallow a later identical keystroke (defect 1)', () => {
const h = harness();
// First keystroke: orphaned, recovered.
h.keydown();
h.input('x');
h.flushTimers();
expect(h.emitted).toEqual(['x']);
// Second identical keystroke, handled by xterm itself.
h.keydown();
h.input('x');
h.controller.notifyCanonicalData();
h.flushTimers();
// Exactly one recovery total, and the second keystroke's canonical byte was
// never claimed or suppressed by the first one.
expect(h.emitted).toEqual(['x']);
});
it('forwards committed text that no keydown key could describe, exactly once (defect 2)', () => {
const h = harness();
h.keydown({ key: 'Enter' });
h.input('a longer commit');
h.flushTimers();
h.flushTimers();
expect(h.emitted).toEqual(['a longer commit']);
});
it('recovers a GBoard keydown and never reads key or keyCode (defect 3)', () => {
const h = harness();
const reads: string[] = [];
h.controller.handleKeyEvent({
type: 'keydown',
get key() {
reads.push('key');
return 'Unidentified';
},
get keyCode() {
reads.push('keyCode');
return 229;
},
get which() {
reads.push('which');
return 229;
},
});
h.input('x');
h.flushTimers();
expect(h.emitted).toEqual(['x']);
expect(reads).toEqual([]);
});
it('ignores input events that are not committed text', () => {
const h = harness();
for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) {
h.keydown();
h.input('x', { inputType });
}
h.keydown();
h.input('');
h.keydown();
h.textarea.fire('input', { data: null, inputType: 'insertText' });
h.flushTimers();
expect(h.emitted).toEqual([]);
});
it('ignores composition and cancels pending candidates on compositionstart', () => {
const composing = harness();
composing.keydown();
composing.input('x', { isComposing: true });
composing.flushTimers();
expect(composing.emitted).toEqual([]);
const lifecycle = harness();
lifecycle.textarea.fire('compositionstart');
lifecycle.keydown();
lifecycle.input('中');
lifecycle.flushTimers();
expect(lifecycle.emitted).toEqual([]);
// compositionstart arriving after a candidate is queued must cancel it.
const cancelled = harness();
cancelled.keydown();
cancelled.input('x');
cancelled.textarea.fire('compositionstart');
cancelled.flushTimers();
expect(cancelled.emitted).toEqual([]);
// compositionend releases the gate again.
cancelled.textarea.fire('compositionend');
cancelled.keydown();
cancelled.input('y');
cancelled.flushTimers();
expect(cancelled.emitted).toEqual(['y']);
});
it('stays out of the way in screen reader mode', () => {
const h = harness({ screenReader: true });
h.keydown();
h.input('x');
h.flushTimers();
expect(h.emitted).toEqual([]);
});
it('recovers an input event that arrives with no preceding keydown', () => {
const h = harness();
h.input('x');
h.flushTimers();
expect(h.emitted).toEqual(['x']);
});
it('clears timers and listeners on destroy', () => {
const h = harness();
expect(h.textarea.listenerCount()).toBeGreaterThan(0);
h.keydown();
h.input('x');
expect(h.pendingTimers()).toBe(1);
h.controller.destroy();
expect(h.pendingTimers()).toBe(0);
expect(h.textarea.listenerCount()).toBe(0);
h.flushTimers();
expect(h.emitted).toEqual([]);
// Nothing fires after destroy, even if a stray event is delivered.
h.textarea.fire('input', inputEvent('y'));
h.flushTimers();
expect(h.emitted).toEqual([]);
});
});
describe('terminal-ui wiring: what counts as "xterm spoke for this keystroke"', () => {
const terminalSource = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf8');
it('gates notifyCanonicalData on the two predicates this file already owns', () => {
// onData does NOT only carry keystrokes: xterm answers DA/DSR/CPR/OSC
// queries through it during Ink redraws, and emits SGR mouse and focus
// reports on its own initiative. Counting one of those as canonical data
// for the pending keystroke stands the recovery down and leaves the
// character dropped, worst on a busy agent pane, which is the case this
// exists for. Same gate, same two predicates, as the one-shot Ctrl
// modifier uses for the same question (test/mobile-shell-keyboard.test.ts).
const notify = terminalSource.indexOf('_keyCode229Recovery?.notifyCanonicalData?.()');
expect(notify).toBeGreaterThan(0);
const gate = terminalSource.lastIndexOf(
'!input?.shouldSuppressTerminalQueryResponse(data) && !input?.isTerminalFocusOrMouseReport(data)',
notify
);
expect(gate).toBeGreaterThan(0);
expect(gate).toBeLessThan(notify);
// ⚠️ The predicates live inside the module IIFE that ends long before this
// call site, so they are reachable ONLY through the global. Bare references
// would throw a ReferenceError straight into the surrounding try/catch,
// which swallows it, and notifyCanonicalData would then NEVER run: the
// recovery would re-emit a character xterm already delivered.
expect(terminalSource.slice(gate - 120, notify)).toContain('window.CodemanTerminalInput');
});
it('stands down for a real keystroke, but not for a mouse report or a query reply', () => {
// The gate as terminal-ui.js writes it. The wiring test above pins the real
// source; this proves the behaviour it buys.
const input = loadTerminalInput();
const onData = (h: ReturnType<typeof harness>, data: string) => {
if (!input.shouldSuppressTerminalQueryResponse(data) && !input.isTerminalFocusOrMouseReport(data)) {
h.controller.notifyCanonicalData();
}
};
for (const noise of ['\x1b[<0;10;5M', '\x1b[I', '\x1b[?1;2c']) {
const h = harness();
h.keydown();
h.input('x');
onData(h, noise);
h.flushTimers();
expect(h.emitted, `${JSON.stringify(noise)} must not stand the recovery down`).toEqual(['x']);
}
const typed = harness();
typed.keydown();
typed.input('x');
onData(typed, 'x');
typed.flushTimers();
expect(typed.emitted, 'xterm really did deliver this one').toEqual([]);
});
});