mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -1,152 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { dispatchKeybindingAction } from "../../lib/keybinding-dispatch";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { KeybindingDispatchContext } from "../../lib/keybinding-dispatch";
|
||||
import * as actionRegistry from "../../shell/action-registry";
|
||||
|
||||
function makeContext(
|
||||
overrides: Partial<KeybindingDispatchContext> = {},
|
||||
): KeybindingDispatchContext & {
|
||||
sentData: string[];
|
||||
} {
|
||||
const sentData: string[] = [];
|
||||
const ws = {
|
||||
readyState: 1,
|
||||
send: vi.fn((raw: string) => {
|
||||
sentData.push(JSON.parse(raw).data);
|
||||
}),
|
||||
};
|
||||
|
||||
const ctx: KeybindingDispatchContext & { sentData: string[] } = {
|
||||
terminal: {
|
||||
getSelection: vi.fn(() => ""),
|
||||
clearSelection: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
} as unknown as Terminal,
|
||||
webSocketRef: { current: ws as unknown as WebSocket },
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(true),
|
||||
readTextFromClipboard: vi.fn().mockResolvedValue(""),
|
||||
sentData,
|
||||
...overrides,
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
describe("dispatchKeybindingAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("copy writes the selection to clipboard and clears it", () => {
|
||||
const ctx = makeContext();
|
||||
(ctx.terminal.getSelection as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
"hello",
|
||||
);
|
||||
dispatchKeybindingAction({ type: "copy" }, ctx);
|
||||
expect(ctx.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(ctx.terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copy does nothing when there is no selection", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "copy" }, ctx);
|
||||
expect(ctx.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
expect(ctx.terminal.clearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("paste reads the clipboard and pastes into the terminal", async () => {
|
||||
const ctx = makeContext({
|
||||
readTextFromClipboard: vi.fn().mockResolvedValue("pasted text"),
|
||||
});
|
||||
dispatchKeybindingAction({ type: "paste" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.terminal.paste).toHaveBeenCalledWith("pasted text");
|
||||
});
|
||||
|
||||
it("sendControlCode sends the corresponding control byte", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction(
|
||||
{ type: "sendControlCode", controlCode: "c" },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["\x03"]);
|
||||
});
|
||||
|
||||
it("sendText sends literal text without a trailing return by default", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "sendText", text: "ls -la" }, ctx);
|
||||
expect(ctx.sentData).toEqual(["ls -la"]);
|
||||
});
|
||||
|
||||
it("sendText appends \\r when appendEnter is true", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction(
|
||||
{ type: "sendText", text: "ls -la", appendEnter: true },
|
||||
ctx,
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["ls -la\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet resolves the snippet through the snippets.resolveForTerminal action and sends its content with a trailing return", async () => {
|
||||
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
|
||||
needsInputs: false,
|
||||
content: "uptime",
|
||||
});
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(actionRegistry.invokeAction).toHaveBeenCalledWith(
|
||||
"snippets.resolveForTerminal",
|
||||
1,
|
||||
null,
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["uptime\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet does nothing when the snippet no longer exists", async () => {
|
||||
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue(undefined);
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual([]);
|
||||
});
|
||||
|
||||
it("runSnippet passes hostContext through to the resolve action", async () => {
|
||||
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
|
||||
needsInputs: false,
|
||||
content: "ping 10.0.0.5 -p 22",
|
||||
});
|
||||
const ctx = makeContext({
|
||||
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
|
||||
});
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(actionRegistry.invokeAction).toHaveBeenCalledWith(
|
||||
"snippets.resolveForTerminal",
|
||||
1,
|
||||
{ ip: "10.0.0.5", username: "root", port: 22 },
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["ping 10.0.0.5 -p 22\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet defers to onSnippetNeedsInputs instead of sending when the action reports needsInputs", async () => {
|
||||
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
|
||||
needsInputs: true,
|
||||
content: "echo $INPUT_1",
|
||||
});
|
||||
const onSnippetNeedsInputs = vi.fn();
|
||||
const ctx = makeContext({ onSnippetNeedsInputs });
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual([]);
|
||||
expect(onSnippetNeedsInputs).toHaveBeenCalledWith({
|
||||
id: "1",
|
||||
content: "echo $INPUT_1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getMacLineNavigationSequence } from "../../lib/mac-line-navigation";
|
||||
|
||||
function keyEvent(
|
||||
init: Partial<KeyboardEvent> & Pick<KeyboardEvent, "code">,
|
||||
): KeyboardEvent {
|
||||
return {
|
||||
type: "keydown",
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...init,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("getMacLineNavigationSequence", () => {
|
||||
it("maps Cmd+arrow keys on macOS", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowLeft", metaKey: true }),
|
||||
),
|
||||
).toBe("\x01");
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowRight", metaKey: true }),
|
||||
),
|
||||
).toBe("\x05");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores modified and non-mac shortcuts", () => {
|
||||
vi.stubGlobal("navigator", { platform: "MacIntel" });
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowLeft", metaKey: true, shiftKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
vi.stubGlobal("navigator", { platform: "Win32" });
|
||||
expect(
|
||||
getMacLineNavigationSequence(
|
||||
keyEvent({ code: "ArrowLeft", metaKey: true }),
|
||||
),
|
||||
).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
} from "@/lib/terminal-look/terminal-font-zoom";
|
||||
|
||||
function keyEvent(
|
||||
overrides: Partial<KeyboardEvent>,
|
||||
): Pick<KeyboardEvent, "altKey" | "code" | "ctrlKey" | "key" | "metaKey"> {
|
||||
return {
|
||||
altKey: false,
|
||||
code: "",
|
||||
ctrlKey: false,
|
||||
key: "",
|
||||
metaKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getTerminalFontZoomDirection", () => {
|
||||
it("recognizes Ctrl+Plus and Ctrl+Minus", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Equal", key: "+" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Minus", key: "-" }),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("recognizes unshifted equal, numpad, and macOS shortcuts", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "Equal", key: "=" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ ctrlKey: true, code: "NumpadAdd", key: "+" }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ metaKey: true, code: "NumpadSubtract", key: "-" }),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("ignores unmodified and Alt-modified keys", () => {
|
||||
expect(
|
||||
getTerminalFontZoomDirection(keyEvent({ code: "Equal", key: "+" })),
|
||||
).toBe(0);
|
||||
expect(
|
||||
getTerminalFontZoomDirection(
|
||||
keyEvent({ altKey: true, ctrlKey: true, code: "Minus", key: "-" }),
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextTerminalFontSize", () => {
|
||||
it("changes the font size one pixel at a time", () => {
|
||||
expect(getNextTerminalFontSize(14, 1)).toBe(15);
|
||||
expect(getNextTerminalFontSize(14, -1)).toBe(13);
|
||||
});
|
||||
|
||||
it("keeps the existing zoom limits", () => {
|
||||
expect(getNextTerminalFontSize(36, 1)).toBe(36);
|
||||
expect(getNextTerminalFontSize(8, -1)).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
|
||||
|
||||
describe("ensureTerminalFontsLoaded", () => {
|
||||
it("requests regular, bold, italic, and bold-italic variants for the given font", () => {
|
||||
const load = vi.fn().mockResolvedValue([]);
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { load },
|
||||
});
|
||||
|
||||
try {
|
||||
ensureTerminalFontsLoaded("Caskaydia Cove Nerd Font Mono");
|
||||
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'400 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'700 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'italic 400 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'italic 700 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw when document.fonts is unavailable", () => {
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureTerminalFontsLoaded("JetBrains Mono")).not.toThrow();
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("swallows rejected font load promises", async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { load },
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureTerminalFontsLoaded("Fira Code")).not.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,441 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
highlightTerminalOutput,
|
||||
updateControlStringMode,
|
||||
} from "../../lib/terminal-syntax-highlighter.js";
|
||||
|
||||
const ESC = "\x1b";
|
||||
|
||||
describe("highlightTerminalOutput", () => {
|
||||
it("returns empty/whitespace text unchanged", () => {
|
||||
expect(highlightTerminalOutput("")).toBe("");
|
||||
expect(highlightTerminalOutput(" ")).toBe(" ");
|
||||
});
|
||||
|
||||
it("wraps ERROR in bright red", () => {
|
||||
const out = highlightTerminalOutput("Something ERROR happened");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
expect(out).toContain("ERROR");
|
||||
});
|
||||
|
||||
it("wraps lowercase error in bright red (case-insensitive)", () => {
|
||||
const out = highlightTerminalOutput("something error happened");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("wraps Error (mixed case) in bright red", () => {
|
||||
const out = highlightTerminalOutput("Error: connection refused");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("wraps warn (lowercase) in bright yellow", () => {
|
||||
const out = highlightTerminalOutput("warn: disk almost full");
|
||||
expect(out).toContain(ESC + "[93m");
|
||||
});
|
||||
|
||||
it("wraps warning (lowercase) in bright yellow", () => {
|
||||
const out = highlightTerminalOutput("warning: something is off");
|
||||
expect(out).toContain(ESC + "[93m");
|
||||
});
|
||||
|
||||
it("highlights IPv4 addresses in magenta", () => {
|
||||
const out = highlightTerminalOutput("connect to 192.168.1.10 now");
|
||||
expect(out).toContain("192.168.1.10");
|
||||
expect(out).toContain(ESC + "[35m");
|
||||
});
|
||||
|
||||
it("leaves text with no matchable tokens unchanged", () => {
|
||||
const plain = "just a normal sentence here";
|
||||
expect(highlightTerminalOutput(plain)).toBe(plain);
|
||||
});
|
||||
|
||||
it("leaves text ending in an incomplete ANSI escape untouched", () => {
|
||||
const partial = `loading${ESC}[`;
|
||||
expect(highlightTerminalOutput(partial)).toBe(partial);
|
||||
});
|
||||
|
||||
it("skips TUI cursor-positioning frames (nano/vim protection)", () => {
|
||||
const tuiFrame = `${ESC}[H${ESC}[2J some nano content`;
|
||||
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
|
||||
});
|
||||
|
||||
it("skips private TUI mode sequences", () => {
|
||||
const tuiFrame = `${ESC}[?1049h${ESC}[?25lERROR at /var/log/app.log`;
|
||||
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
|
||||
});
|
||||
|
||||
it("skips bracketed paste mode sequences", () => {
|
||||
const chunk = `${ESC}[?2004hERROR: enabled paste mode`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips TUI frames with box-drawing characters", () => {
|
||||
const chunk = `┌─ /var/log ─┐\n│ ERROR file │`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips DEC line-drawing charset frames", () => {
|
||||
const chunk = `${ESC}(0lqq /var/log qk${ESC}(B`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips OSC current-directory sequences used by fish prompts", () => {
|
||||
const chunk = `${ESC}]7;file://server/home/user/docker/sonarr\x07user@server ~/docker/sonarr> `;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("skips OSC title sequences", () => {
|
||||
const chunk = `${ESC}]0;user@server:/var/log\x07ERROR after title`;
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("highlights text that already has ANSI codes", () => {
|
||||
let heavy = "";
|
||||
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
|
||||
heavy += "ERROR at line 5";
|
||||
const out = highlightTerminalOutput(heavy);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("does not process lines exceeding MAX_LINE_LENGTH", () => {
|
||||
const huge = "ERROR " + "x".repeat(3000);
|
||||
expect(highlightTerminalOutput(huge)).toBe(huge);
|
||||
});
|
||||
|
||||
it("highlights simple absolute paths in cyan", () => {
|
||||
const out = highlightTerminalOutput("log at /var/log/nginx/access.log");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("/var/log/nginx/access.log");
|
||||
});
|
||||
|
||||
it("highlights absolute paths with glob wildcards in cyan", () => {
|
||||
const out = highlightTerminalOutput("files: /usr/share/doc/*/copyright");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("/usr/share/doc/*/copyright");
|
||||
});
|
||||
|
||||
it("highlights home paths in cyan", () => {
|
||||
const out = highlightTerminalOutput("file: ~/documents/notes.txt");
|
||||
expect(out).toContain(ESC + "[36m");
|
||||
expect(out).toContain("~/documents/notes.txt");
|
||||
});
|
||||
|
||||
it("highlights bracket timestamps in bright black", () => {
|
||||
const out = highlightTerminalOutput("[12:34:56] server started");
|
||||
expect(out).toContain(ESC + "[90m");
|
||||
expect(out).toContain("[12:34:56]");
|
||||
});
|
||||
|
||||
it("does not highlight out-of-range bracket timestamps", () => {
|
||||
// [99:99] is not a valid time
|
||||
const out = highlightTerminalOutput("[99:99] something");
|
||||
expect(out).not.toContain(ESC + "[90m");
|
||||
});
|
||||
|
||||
it("highlights ISO date timestamps in bright black", () => {
|
||||
const out = highlightTerminalOutput("2024-01-15 event occurred");
|
||||
expect(out).toContain(ESC + "[90m");
|
||||
expect(out).toContain("2024-01-15");
|
||||
});
|
||||
|
||||
it("does not highlight version strings as ISO dates", () => {
|
||||
// 2.4.0 or 1.2024.3 should not trigger ISO date
|
||||
const out = highlightTerminalOutput("version 2.4.0 released");
|
||||
expect(out).toBe("version 2.4.0 released");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (port)", () => {
|
||||
const out = highlightTerminalOutput("listening on port 8080");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
expect(out).toContain("8080");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (exit)", () => {
|
||||
const out = highlightTerminalOutput("process exited with exit 1");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("highlights labeled numbers (status)", () => {
|
||||
const out = highlightTerminalOutput("returned status 404");
|
||||
expect(out).toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("does not highlight standalone numbers outside labeled context", () => {
|
||||
const out = highlightTerminalOutput("there are 42 files here");
|
||||
expect(out).toBe("there are 42 files here");
|
||||
});
|
||||
|
||||
it("does not highlight 'up' or 'active' as success", () => {
|
||||
const out = highlightTerminalOutput("service is up and running");
|
||||
expect(out).not.toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights unambiguous success keywords (case-insensitive)", () => {
|
||||
const out = highlightTerminalOutput("Test passed successfully");
|
||||
expect(out).toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights URLs with blue+underline", () => {
|
||||
const out = highlightTerminalOutput("visit https://example.com now");
|
||||
expect(out).toContain(ESC + "[34m");
|
||||
expect(out).toContain(ESC + "[4m");
|
||||
});
|
||||
|
||||
it("preserves \\r in CRLF terminal output", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||
expect(out).toContain("\r\n");
|
||||
});
|
||||
|
||||
it("processes multi-line text line by line", () => {
|
||||
const text = "some output\nERROR: failed\n";
|
||||
const out = highlightTerminalOutput(text);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling logLevels skips error highlight", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred", { logLevels: false });
|
||||
expect(out).not.toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling paths skips path highlight", () => {
|
||||
const out = highlightTerminalOutput("at /usr/share/doc/something/file", {
|
||||
paths: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[36m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling urls skips url highlight", () => {
|
||||
const out = highlightTerminalOutput("see https://example.com", {
|
||||
urls: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[34m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling ipAddresses skips IP highlight", () => {
|
||||
const out = highlightTerminalOutput("connect to 192.168.1.1", {
|
||||
ipAddresses: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[35m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling timestamps skips ISO date highlight", () => {
|
||||
const out = highlightTerminalOutput("2024-01-15 something", {
|
||||
timestamps: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[90m");
|
||||
});
|
||||
|
||||
it("respects category options - disabling numbers skips port highlight", () => {
|
||||
const out = highlightTerminalOutput("listening on port 8080", {
|
||||
numbers: false,
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("skips highlighting when chunk contains a mid-line carriage return", () => {
|
||||
// Progress bars and shell prompt redraws use \r to overwrite the current line
|
||||
const chunk = "downloading...\rdownloading [====] 100%";
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("still processes CRLF line endings (\\r\\n is fine)", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("does not highlight shell prompt lines (user@host:path$)", () => {
|
||||
const prompt = `${ESC}[01;32mpi@raspberrypi${ESC}[00m:${ESC}[01;34m/home/pi${ESC}[00m$ `;
|
||||
const out = highlightTerminalOutput(prompt);
|
||||
expect(out).toBe(prompt);
|
||||
});
|
||||
|
||||
it("does not highlight plain-text shell prompt line", () => {
|
||||
const prompt = "pi@raspberrypi:/home/pi$ ";
|
||||
const out = highlightTerminalOutput(prompt);
|
||||
expect(out).toBe(prompt);
|
||||
});
|
||||
|
||||
it("does not highlight log keywords inside bracketed SSH headings", () => {
|
||||
const out = highlightTerminalOutput("[info@archlinux] command output");
|
||||
expect(out).toBe("[info@archlinux] command output");
|
||||
});
|
||||
|
||||
it("still highlights log keywords after a bracketed SSH heading", () => {
|
||||
const out = highlightTerminalOutput("[warning@host] command ERROR");
|
||||
expect(out).not.toContain(`${ESC}[93mwarning`);
|
||||
expect(out).toContain(`${ESC}[91mERROR`);
|
||||
});
|
||||
|
||||
it("does not highlight a log-level-like username split across ANSI segments in a colored SSH heading", () => {
|
||||
// Prompt themes often color the user and host portions of "[user@host]"
|
||||
// separately, so the heading is not one contiguous plain-text segment.
|
||||
const chunk = `[${ESC}[1;33mwarning${ESC}[0m@host] some command output`;
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
expect(out).toBe(chunk);
|
||||
});
|
||||
|
||||
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
|
||||
// Some shells print "success~/new/dir" or "success/path" after a cd command
|
||||
const out = highlightTerminalOutput("success~/home/user/projects");
|
||||
expect(out).not.toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("still highlights 'success' when not followed by a path separator", () => {
|
||||
const out = highlightTerminalOutput("Build success: all tests passed");
|
||||
expect(out).toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights output lines but not the prompt in a mixed chunk", () => {
|
||||
const chunk = `ERROR: disk full\npi@raspberrypi:~$ `;
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
// The error line should be highlighted
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
// The prompt line should be unchanged
|
||||
expect(out).toContain("pi@raspberrypi:~$ ");
|
||||
const promptLine = out.split("\n")[1];
|
||||
expect(promptLine).toBe("pi@raspberrypi:~$ ");
|
||||
});
|
||||
|
||||
it("does not highlight paths inside a command-echo line (prompt + command)", () => {
|
||||
// When the shell echoes the user's command, it prefixes the prompt.
|
||||
// The path in the prompt portion (/home/user) must not be highlighted —
|
||||
// the shell already colored it, and re-coloring it causes the doubled-path bug.
|
||||
const echo = "user@host:/home/user$ cd /opt/app/bin/files";
|
||||
const out = highlightTerminalOutput(echo);
|
||||
expect(out).toBe(echo);
|
||||
});
|
||||
|
||||
it("does not highlight paths in colored command-echo lines (root prompt)", () => {
|
||||
const echo = `${ESC}[01;32mroot@host${ESC}[00m:${ESC}[01;34m/home/user${ESC}[00m# cd /opt/app/bin/files`;
|
||||
const out = highlightTerminalOutput(echo);
|
||||
expect(out).toBe(echo);
|
||||
});
|
||||
|
||||
it("does not highlight the last line of a multi-line chunk with no trailing newline", () => {
|
||||
// In a multi-line chunk, the trailing fragment without \n could be a live
|
||||
// readline input line. Injecting ANSI bytes there breaks bash's cursor
|
||||
// arithmetic (causes cursor jump / text shift when using arrow keys).
|
||||
const chunk = "ERROR: disk full\nuser@host:/path$ cd /var/log";
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
// First line (terminated by \n) gets highlighted
|
||||
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||
// Last unterminated fragment is left verbatim
|
||||
expect(out.split("\n")[1]).toBe("user@host:/path$ cd /var/log");
|
||||
});
|
||||
|
||||
it("still highlights a single-line chunk with no trailing newline", () => {
|
||||
// A single-line chunk with no \n is plain command output, not a readline
|
||||
// input fragment — highlight it normally.
|
||||
const out = highlightTerminalOutput("ERROR: something failed");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("highlights all lines when the chunk ends with a newline", () => {
|
||||
// When a chunk ends with \n every line is complete output — highlight them all.
|
||||
const chunk = "ERROR: disk full\nconnect to /var/run/app.sock\n";
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||
expect(out.split("\n")[1]).toContain(ESC + "[36m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateControlStringMode", () => {
|
||||
const BEL = "\x07";
|
||||
|
||||
it("stays inactive for output with no control string", () => {
|
||||
expect(updateControlStringMode("total 4\nfile.txt\n", false)).toEqual({
|
||||
isActive: false,
|
||||
wasActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("goes active when a control string is left open at the chunk end", () => {
|
||||
// What PROMPT_COMMAND emits: ESC ] 0 ; <title>, terminator not yet sent.
|
||||
const chunk = `${ESC}]0;user@host:~/path/current-dir`;
|
||||
|
||||
expect(updateControlStringMode(chunk, false)).toEqual({
|
||||
isActive: true,
|
||||
wasActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears on the continuation chunk that carries the terminator", () => {
|
||||
// The continuation has no escape byte at all, which is why every
|
||||
// single-chunk guard misses it.
|
||||
const continuation = `${BEL}[user@host current-dir]$ `;
|
||||
|
||||
expect(updateControlStringMode(continuation, true)).toEqual({
|
||||
isActive: false,
|
||||
wasActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a chunk that opens and closes a control string as inactive", () => {
|
||||
const chunk = `${ESC}]0;title${BEL}ready\n`;
|
||||
|
||||
expect(updateControlStringMode(chunk, false)).toEqual({
|
||||
isActive: false,
|
||||
wasActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts ST as a terminator", () => {
|
||||
expect(
|
||||
updateControlStringMode(`${ESC}]7;file:///tmp${ESC}\\`, false),
|
||||
).toEqual({
|
||||
isActive: false,
|
||||
wasActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a trailing lone ESC as active, since its meaning is in the next chunk", () => {
|
||||
expect(updateControlStringMode(`done${ESC}`, false)).toEqual({
|
||||
isActive: true,
|
||||
wasActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises DCS, APC and PM openers too", () => {
|
||||
for (const opener of ["P", "^", "_"]) {
|
||||
expect(
|
||||
updateControlStringMode(`${ESC}${opener}payload`, false).isActive,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat CSI as a control string", () => {
|
||||
expect(updateControlStringMode(`${ESC}[31mred${ESC}[0m`, false)).toEqual({
|
||||
isActive: false,
|
||||
wasActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a continuation chunk unhighlighted", () => {
|
||||
// The bug: this chunk highlights cleanly on its own, and injecting SGR
|
||||
// bytes into an open OSC string dumps the payload onto the screen.
|
||||
const continuation = `${BEL}connect to 10.0.0.11 failed`;
|
||||
expect(highlightTerminalOutput(continuation)).not.toBe(continuation);
|
||||
|
||||
const state = updateControlStringMode(continuation, true);
|
||||
expect(state.wasActive).toBe(true);
|
||||
});
|
||||
|
||||
it("passes through SGR mouse-tracking reports untouched (issue #1023)", () => {
|
||||
// ESC[<Cb;Cx;CyM is the SGR (1006) mouse-report format. The `<` marker
|
||||
// byte was missing from the parameter-byte character class, so these
|
||||
// sequences weren't recognised as ANSI escapes and their raw digits
|
||||
// leaked onto the screen as visible text inside `screen`/tmux sessions.
|
||||
const report = `${ESC}[<35;191;1M`;
|
||||
expect(highlightTerminalOutput(report)).toBe(report);
|
||||
});
|
||||
|
||||
it("passes through a burst of SGR mouse reports untouched", () => {
|
||||
const burst = `${ESC}[<35;191;1M${ESC}[<35;186;2M${ESC}[<35;46;21M`;
|
||||
expect(highlightTerminalOutput(burst)).toBe(burst);
|
||||
});
|
||||
|
||||
it("treats a chunk ending mid SGR-mouse-report as incomplete", () => {
|
||||
const partial = `foo${ESC}[<35;191`;
|
||||
expect(highlightTerminalOutput(partial)).toBe(partial);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user