hermes-agent/web/src/lib/pty-mobile-input.test.ts
kshitijk4poor 6f42bf344c fix(dashboard): harden PTY reconnect race, wedged-connect recovery, IME guard
Follow-up hardening on the salvaged NS-591 mobile-chat reconnect fix, from
review findings:

- Guard the page-resume reconnect against the async socket-open window:
  a connectInFlightRef is set synchronously before the ticket-URL await so
  a visibilitychange/focus fired during that gap (wsRef still null) can't
  spawn a redundant second socket. Threaded through
  shouldReconnectPtyOnPageResume as connectInFlight.
- Recover a socket wedged in WS_CONNECTING (half-open mobile socket after a
  radio handoff — the NS-591 scenario) via a PTY_CONNECTING_TIMEOUT_MS
  force-close so onclose routes into scheduleReconnect. Cleared on
  open/close/effect-cleanup.
- Avoid collapsing legitimate single-letter reduplication ("a a") in the
  mobile duplicate-final-word heuristic (>=2-char guard).
- Extract the 350ms replacement window and 1000ms resume throttle to named
  exported consts; drop the dead WS_CONNECTING term from the resume
  predicate's final expression.

Adds tests for the in-flight guard and the single-letter reduplication case.
2026-07-09 13:50:17 +05:30

100 lines
3.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
normalizePtyMobileInput,
shouldTreatInputAsMobileReplacement,
updatePtyInputLine,
} from "./pty-mobile-input";
describe("shouldTreatInputAsMobileReplacement", () => {
it("recognizes explicit browser replacement input", () => {
expect(shouldTreatInputAsMobileReplacement("insertReplacementText", "Kain", false)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertFromComposition", "Kain", false)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertCompositionText", "Kain", false)).toBe(true);
});
it("treats multi-character mobile insertText as replacement-like", () => {
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", true)).toBe(true);
expect(shouldTreatInputAsMobileReplacement("insertText", "K", true)).toBe(false);
expect(shouldTreatInputAsMobileReplacement("insertText", "Kain", false)).toBe(false);
});
});
describe("normalizePtyMobileInput", () => {
it("turns a Gboard full-line suggestion into a line replacement", () => {
const result = normalizePtyMobileInput(
"hello my name is Kain Kain",
"hello my name is kain",
true,
);
expect(result.normalized).toBe(true);
expect(result.nextLine).toBe("hello my name is Kain");
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
});
it("turns a Gboard last-word suggestion into a last-word replacement", () => {
const result = normalizePtyMobileInput("Kain", "hello my name is kain", true);
expect(result.normalized).toBe(true);
expect(result.nextLine).toBe("hello my name is Kain");
expect(result.data).toBe("\x7f".repeat("hello my name is kain".length) + "hello my name is Kain");
});
it("does not normalize ordinary appends when replacement is not active", () => {
const result = normalizePtyMobileInput(
"hello my name is Kain Kain",
"hello my name is kain",
false,
);
expect(result.normalized).toBe(false);
expect(result.nextLine).toBe("hello my name is kainhello my name is Kain Kain");
});
it("does not normalize control input", () => {
const result = normalizePtyMobileInput("\r", "hello", true);
expect(result.normalized).toBe(false);
expect(result.nextLine).toBe("");
expect(result.data).toBe("\r");
});
it("does not collapse legitimate single-letter reduplication", () => {
// "a a" is a plausible thing to type; the >=2-char guard keeps the
// duplicate-final-word collapse from eating it inside the window.
const result = normalizePtyMobileInput("a a", "a", true);
expect(result.normalized).toBe(false);
expect(result.data).toBe("a a");
});
});
describe("updatePtyInputLine", () => {
it("tracks printable text, delete, and submit", () => {
expect(updatePtyInputLine("", "abc")).toBe("abc");
expect(updatePtyInputLine("abc", "\x7f")).toBe("ab");
expect(updatePtyInputLine("abc", "\r")).toBe("");
});
it("resets tracking on escape sequences instead of appending their payload", () => {
// Left-arrow arrives as one CSI chunk; the tracker cannot model cursor
// moves, so it must disarm rather than record "hello[D".
expect(updatePtyInputLine("hello", "\x1b[D")).toBe("");
expect(updatePtyInputLine("hello", "\x1b[H")).toBe("");
expect(updatePtyInputLine("hello", "\x1bOP")).toBe("");
});
});
describe("normalizePtyMobileInput after cursor movement", () => {
it("does not emit a replacement against a tracker reset by arrow keys", () => {
// Simulate: type "hello my name is kain", press left-arrow, then a
// Gboard suggestion arrives. The tracker reset means no replacement
// heuristic can fire against a stale line snapshot.
const afterArrow = updatePtyInputLine("hello my name is kain", "\x1b[D");
const result = normalizePtyMobileInput("Kain", afterArrow, true);
expect(result.normalized).toBe(false);
expect(result.data).toBe("Kain");
});
});