Files
Netcatty/components/terminal/GhostTextAddon.test.ts
T

696 lines
21 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
type RenderListener = () => void;
type ResizeListener = () => void;
class FakeElement {
public readonly style: Record<string, string> = {};
public textContent = "";
public className = "";
public children: FakeElement[] = [];
appendChild(child: FakeElement): FakeElement {
this.children.push(child);
return child;
}
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
if (!referenceNode) {
this.children.push(child);
return child;
}
const index = this.children.indexOf(referenceNode);
if (index < 0) {
this.children.push(child);
return child;
}
this.children.splice(index, 0, child);
return child;
}
remove(): void {
// No-op for tests.
}
querySelector(selector: string): FakeElement | null {
if (selector === ".xterm-screen") {
return this.children.find((child) => child.className === "xterm-screen") ?? null;
}
return null;
}
}
function installFakeDocument(): () => void {
const previousDocument = globalThis.document;
const fakeDocument = {
createElement() {
return new FakeElement();
},
} as unknown as Document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: fakeDocument,
});
return () => {
if (previousDocument === undefined) {
delete (globalThis as { document?: Document }).document;
return;
}
Object.defineProperty(globalThis, "document", {
configurable: true,
value: previousDocument,
});
};
}
function createFakeTerm() {
const renderListeners: RenderListener[] = [];
const resizeListeners: ResizeListener[] = [];
const element = new FakeElement();
const screen = new FakeElement();
screen.className = "xterm-screen";
element.appendChild(screen);
const term = {
element,
cols: 80,
rows: 24,
options: {
fontSize: 14,
fontFamily: "monospace",
},
buffer: {
active: {
cursorX: 2,
cursorY: 0,
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: {
width: 9,
height: 18,
},
},
},
},
},
onRender(listener: RenderListener) {
renderListeners.push(listener);
return {
dispose() {
const index = renderListeners.indexOf(listener);
if (index >= 0) renderListeners.splice(index, 1);
},
};
},
onResize(listener: ResizeListener) {
resizeListeners.push(listener);
return {
dispose() {
const index = resizeListeners.indexOf(listener);
if (index >= 0) resizeListeners.splice(index, 1);
},
};
},
};
return {
term,
ghostElement: () => screen.children[0]?.children[0] ?? null,
fireRender() {
for (const listener of [...renderListeners]) listener();
},
};
}
test("shifts ghost to predicted cursor column as matching input is typed", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.display, "block");
assert.equal(ghost.textContent, "cker");
// show() anchored at cursorX=2, cell width=9 → left=18.
assert.equal(ghost.style.left, "18px");
addon.adjustToInput("doc");
// After one matching char, the ghost predicts the cursor has moved
// to column 3 and trims "c" from the tail so the next char starts
// where the echo will land. Not waiting for xterm's render keeps
// ghost + real input aligned across SSH echo latency.
assert.equal(ghost.style.display, "block");
assert.equal(ghost.textContent, "ker");
assert.equal(ghost.style.left, "27px");
assert.equal(addon.getGhostText(), "ker");
} finally {
restoreDocument();
}
});
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
addon.adjustToInput("doc");
assert.equal(ghost.textContent, "ker");
assert.equal(ghost.style.left, "27px");
// Backspace below the anchor input — the ghost should shift *left*,
// not stay pinned at the show-time anchor column. Pinning would
// leave a visual gap between the real cursor and the ghost.
addon.adjustToInput("d");
assert.equal(ghost.textContent, "ocker");
// anchor was cursorX=2 captured at show(); "d" is 1 char below
// anchorInputLength=2 → predicted cursor column = 1.
assert.equal(ghost.style.left, "9px");
// Backspace past the anchor back to empty: left is clamped at 0.
addon.adjustToInput("");
assert.equal(ghost.textContent, "docker");
assert.equal(ghost.style.left, "0px");
} finally {
restoreDocument();
}
});
test("advances the anchor by two cells when a CJK glyph is typed", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// Suggestion starts with a CJK char so the prefix-match survives
// the next keystroke.
addon.show("你好世界", "");
const ghost = ghostElement();
assert.ok(ghost);
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
assert.equal(ghost.style.left, "18px");
addon.adjustToInput("你");
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
assert.equal(ghost.textContent, "好世界");
assert.equal(ghost.style.left, "36px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
// wrap to col 3 of row 1.
term.cols = 10;
term.buffer.active.cursorX = 8;
addon.activate(term as never);
addon.show("abcdefghij", "ab");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.top, "0px");
addon.adjustToInput("abcde");
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
// cellWidth=9, cellHeight=18.
assert.equal(ghost.textContent, "fghij");
assert.equal(ghost.style.left, "9px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// show() captures cursorX=2 — simulate this firing during the
// keystroke→echo gap by later advancing the live cursor and
// verifying the ghost anchor snaps to the echoed position.
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.left, "18px");
term.buffer.active.cursorX = 5;
fireRender();
// Input hasn't moved from the show-time baseline, so updatePosition
// re-reads live cursor: new left = 5 * 9 = 45px.
assert.equal(ghost.style.left, "45px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
term.cols = 10;
term.buffer.active.cursorX = 1;
term.buffer.active.cursorY = 1;
addon.activate(term as never);
// Anchored at row 1 col 1 with 5 chars already typed.
addon.show("abcdefghij", "abcde");
const ghost = ghostElement();
assert.ok(ghost);
// Backspace back to 2 chars — delta = -3 across a row boundary.
addon.adjustToInput("ab");
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
assert.equal(ghost.textContent, "cdefghij");
assert.equal(ghost.style.left, "72px");
assert.equal(ghost.style.top, "0px");
} finally {
restoreDocument();
}
});
test("hides ghost immediately when input no longer matches suggestion", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.display, "block");
addon.adjustToInput("dox");
assert.equal(ghost.style.display, "none");
assert.equal(ghost.textContent, "");
assert.equal(addon.isActive(), false);
} finally {
restoreDocument();
}
});
test("applyKeystroke: printable char trims ghost tail when buffer is unreliable (issue #906)", () => {
// Repro for issue #906: after Tab passes to shell and the typed-buffer
// is flagged unreliable, the ghost addon's currentInput is the only
// source of truth for what the user has typed since the last show().
// Without applyKeystroke, line 798's reliability gate prevents
// adjustToInput from firing and the ghost retains its show-time tail
// — when the next keystroke advances the cursor, the stale tail
// overlaps the just-typed glyph (e.g., typing 't' after 'systemctl s'
// makes the screen read 'systemctl sttop firewalld').
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("systemctl stop firewalld", "systemctl s");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "top firewalld");
addon.applyKeystroke("t");
// Ghost tail must shrink by exactly one char so when the shell
// echoes 't', the next visible glyph after the cursor is 'o', not
// 't' (which would render as 'sttop').
assert.equal(ghost.textContent, "op firewalld");
assert.equal(addon.isActive(), true);
} finally {
restoreDocument();
}
});
test("applyKeystroke: backspace re-grows ghost tail by one char", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "doc");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "ker");
addon.applyKeystroke("\x7f");
assert.equal(ghost.textContent, "cker");
} finally {
restoreDocument();
}
});
test("applyKeystroke: Ctrl+W word-erases trailing word from currentInput", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// Mid-suggestion: user has typed two words; Ctrl+W should drop the
// tail word and let the ghost regrow to cover what was erased.
addon.show("git commit -m wip", "git com");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "mit -m wip");
addon.applyKeystroke("\x17");
// The same /\s*\S+\s*$/ regex used by handleInput consumes the
// leading whitespace too, so "git com" → "git"; the ghost regrows
// to cover the now-uncovered leading space + remainder.
assert.equal(ghost.textContent, " commit -m wip");
} finally {
restoreDocument();
}
});
test("applyKeystroke: hides ghost when next char diverges from suggestion", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
// 'x' breaks the prefix invariant — ghost must hide immediately so
// a → -accept after this point can't pull a stale tail onto a line
// that no longer matches the suggestion.
addon.applyKeystroke("x");
assert.equal(ghost.style.display, "none");
assert.equal(addon.isActive(), false);
} finally {
restoreDocument();
}
});
test("applyKeystroke: ignores non-typing data (escape sequences, control codes)", () => {
// Escape sequences and other control codes are routed through
// clearState() in handleInput, not propagated to the ghost — but we
// want applyKeystroke to be a safe no-op if accidentally called with
// them (defense in depth).
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
const tailBefore = ghost.textContent;
addon.applyKeystroke("\x1b[A"); // up-arrow escape sequence
addon.applyKeystroke("\x01"); // Ctrl+A
addon.applyKeystroke(""); // empty
assert.equal(ghost.textContent, tailBefore);
assert.equal(addon.isActive(), true);
} finally {
restoreDocument();
}
});
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// We believe only "network in" is typed; suggestion is the full command.
addon.show("network interface show", "network in");
assert.equal(addon.isActive(), true);
// The real line shows MORE than we tracked: a bastion host echoed the
// next char ("t") that our client-side buffer never recorded.
const line = "ecOS# network int";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = line.length;
active.getLine = () => ({ translateToString: () => line });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when TopsecOS-style backspace leaves stale text after the cursor (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// The user deleted back from "system..." to "syst", so the tracked
// input is shorter and the ghost regrows to "em license show".
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
// TopsecOS devices shown in #1060 leave the old suffix visible after
// the cursor while processing Backspace, so the real buffer looks like:
// TopsecOS# syst|em license show
const beforeCursor = "TopsecOS# syst";
const line = `${beforeCursor}em license show`;
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => line });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when only the deleted prefix remains after the cursor (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}em` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when TopsecOS-style backspace leaves stale text after empty input (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# ";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}system license show` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when stale text is followed by right-side status text (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}em 12:34 ok` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("hides the ghost when a stale argument suffix starts with a space (#1060)", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "system");
assert.equal(addon.isActive(), true);
const beforeCursor = "TopsecOS# system";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor} license show` });
fireRender();
assert.equal(addon.isActive(), false);
assert.equal(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("keeps the ghost when unrelated right-side prompt text is visible", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "host# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
fireRender();
assert.equal(addon.isActive(), true);
assert.notEqual(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("keeps the ghost when only right-side spacing overlaps a space-prefixed suffix", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "system");
assert.equal(addon.isActive(), true);
const beforeCursor = "host# system";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
fireRender();
assert.equal(addon.isActive(), true);
assert.notEqual(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});
test("keeps the ghost when adjacent right-side text only shares the first character", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("system license show", "syst");
assert.equal(addon.isActive(), true);
const beforeCursor = "host# syst";
const active = term.buffer.active as Record<string, unknown>;
active.baseY = 0;
active.cursorX = beforeCursor.length;
active.getLine = () => ({ translateToString: () => `${beforeCursor}error` });
fireRender();
assert.equal(addon.isActive(), true);
assert.notEqual(ghostElement()?.style.display, "none");
} finally {
addon.dispose();
restoreDocument();
}
});