feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import {
createMockCtx,
createTestDb,
type TestDb,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { hostImportNormalizer } from "../../src/backend/host-import.js";
import type {
LiveSessionsV1,
TerminalHistoryV1,
} from "../../src/backend/services.js";
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
let db: TestDb | null = null;
let server: TestServer | null = null;
afterEach(async () => {
db?.close();
db = null;
await server?.close();
server = null;
});
describe("ssh-terminal activate", () => {
it("serves the terminal socket", async () => {
server = await startServer();
expect(server.mock.wsRoutes).toEqual([{ path: "/terminal", raw: false }]);
});
it("provides sessions.live and terminal.history", async () => {
server = await startServer();
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
expect(live.getSession("missing")).toBeNull();
expect(live.listForUser("user-1")).toEqual([]);
expect(live.write("missing", "ls\r")).toBe(false);
expect(live.idleTimeoutMinutes()).toBe(30);
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "df -h" },
});
const history = server.mock.services.get(
"terminal.history",
) as TerminalHistoryV1;
const listed = await server.mock.ctx.asUser("user-1", () =>
history.list(1, 10),
);
expect(listed.map((entry) => entry.command)).toEqual(["df -h"]);
});
it("keeps a plugin caller to its own actor's sessions", async () => {
server = await startServer();
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
const other = await server.mock.ctx.asUser("user-2", async () =>
live.listForUser("user-1"),
);
expect(other).toEqual([]);
});
it("uses the admin session timeout for detached sessions", async () => {
server = await startServer({ settings: { sessionTimeoutMinutes: 90 } });
const live = server.mock.services.get("sessions.live") as LiveSessionsV1;
expect(live.idleTimeoutMinutes()).toBe(90);
});
it("offers a host import normalizer", async () => {
server = await startServer();
expect(
server.mock.ctx.registry.consume("ssh-terminal.hostImportNormalizer"),
).toBe(hostImportNormalizer);
});
it.each(["db:own", "network:serve"])(
"fails closed without %s",
async (capability) => {
db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
);
});
describe("host import", () => {
it("carries the terminal switches of an imported host", () => {
expect(hostImportNormalizer({ name: "web" })).toBeNull();
expect(
hostImportNormalizer({
enableTerminal: false,
enableCommandHistory: true,
}),
).toEqual({ enableTerminal: false, enableCommandHistory: true });
});
});
@@ -0,0 +1,92 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it, with the acting user taken from a test header.
*/
export async function startServer(
options: { permissions?: string[]; settings?: Record<string, unknown> } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
settings: options.settings,
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) await dispose();
db.close();
},
};
}
@@ -0,0 +1,191 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
DEFAULT_IMAGE_HOST_PATH,
DEFAULT_IMAGE_MAX_BYTES,
DEFAULT_IMAGE_MAX_COUNT,
DEFAULT_IMAGE_TTL_MS,
defaultImageLocalDir,
parseImageHostPath,
parseImageLocalDir,
parseTerminalImageStorageMode,
resolveTerminalImageStorageSettings,
TERMINAL_IMAGE_STORAGE_KEYS,
} from "../../../src/backend/images/image-storage-settings.js";
import { createFakeContext } from "@termix/plugin-sdk/testing";
import { readImageStorageSettings } from "../../../src/backend/settings.js";
// parseImageLocalDir resolves against the host platform, so a POSIX literal
// becomes a drive-rooted path on Windows. Compare against the same resolution.
function localDir(posixPath: string): string {
return path.resolve(posixPath);
}
function stubSettings(values: Record<string, string> = {}) {
return {
get: async (key: string) => values[key] ?? null,
};
}
const EMPTY_ENV: NodeJS.ProcessEnv = {};
describe("terminal image storage settings", () => {
describe("mode parsing", () => {
it("accepts the three documented modes case-insensitively", () => {
expect(parseTerminalImageStorageMode("auto")).toBe("auto");
expect(parseTerminalImageStorageMode("LOCAL")).toBe("local");
expect(parseTerminalImageStorageMode(" remote-sftp ")).toBe(
"remote-sftp",
);
});
it("rejects unknown modes and non-strings", () => {
expect(parseTerminalImageStorageMode("s3")).toBeNull();
expect(parseTerminalImageStorageMode("")).toBeNull();
expect(parseTerminalImageStorageMode(undefined)).toBeNull();
});
});
describe("path validation", () => {
it("accepts absolute local directories and normalizes them", () => {
expect(parseImageLocalDir("/var/lib/termix/images")).toBe(
localDir("/var/lib/termix/images"),
);
expect(parseImageLocalDir("/var/lib/termix/../termix/images")).toBeNull();
});
it("rejects relative, empty and NUL-containing local directories", () => {
expect(parseImageLocalDir("images")).toBeNull();
expect(parseImageLocalDir("./db/data/images")).toBeNull();
expect(parseImageLocalDir("")).toBeNull();
expect(parseImageLocalDir("/tmp/a\0b")).toBeNull();
});
it("requires the agent-visible host path to be POSIX-absolute", () => {
expect(parseImageHostPath("/tmp/termix-image-v0")).toBe(
"/tmp/termix-image-v0",
);
expect(parseImageHostPath("tmp/termix-image-v0")).toBeNull();
expect(parseImageHostPath("C:\\images")).toBeNull();
expect(parseImageHostPath("/tmp/a\0b")).toBeNull();
});
});
describe("resolution precedence", () => {
it("uses built-in defaults when neither the database nor env has values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
EMPTY_ENV,
);
expect(resolved.mode).toBe("auto");
expect(resolved.localDir).toBe(defaultImageLocalDir(EMPTY_ENV));
expect(resolved.hostPath).toBe(DEFAULT_IMAGE_HOST_PATH);
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
expect(resolved.maxBytes).toBe(DEFAULT_IMAGE_MAX_BYTES);
});
it("seeds defaults from legacy TERMIX_IMAGE_* env when no DB value exists", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_HOST_PATH: "/tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10485760",
},
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.hostPath).toBe("/tmp/images");
expect(resolved.ttlMs).toBe(60_000);
expect(resolved.maxCount).toBe(5);
expect(resolved.maxBytes).toBe(10_485_760);
});
it("lets persisted DB values win over legacy env values", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "/db/images",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "1000",
[TERMINAL_IMAGE_STORAGE_KEYS.maxCount]: "7",
}),
{
TERMIX_IMAGE_DIR: "/host-tmp/images",
TERMIX_IMAGE_TTL_MS: "60000",
TERMIX_MAX_IMAGE_COUNT: "5",
},
);
expect(resolved.localDir).toBe(localDir("/db/images"));
expect(resolved.ttlMs).toBe(1_000);
expect(resolved.maxCount).toBe(7);
});
it("falls through to env and defaults when the DB value is invalid", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: "relative/path",
[TERMINAL_IMAGE_STORAGE_KEYS.ttlMs]: "not-a-number",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.localDir).toBe(localDir("/host-tmp/images"));
expect(resolved.ttlMs).toBe(DEFAULT_IMAGE_TTL_MS);
});
it("keeps legacy explicit local mappings on local mode", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("local");
// hostPath falls back to the legacy local dir, which resolves natively.
expect(resolved.hostPath).toBe(localDir("/host-tmp/images"));
expect(resolved.localMappingConfigured).toBe(true);
});
it("lets a persisted DB mode override the legacy local mapping", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings({
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: "remote-sftp",
}),
{ TERMIX_IMAGE_DIR: "/host-tmp/images" },
);
expect(resolved.mode).toBe("remote-sftp");
});
it("clamps out-of-range numeric values like the legacy env parsing did", async () => {
const resolved = await resolveTerminalImageStorageSettings(
stubSettings(),
{
TERMIX_IMAGE_TTL_MS: "-5",
TERMIX_MAX_IMAGE_COUNT: "0",
TERMIX_MAX_IMAGE_STORAGE_BYTES: "10",
},
);
expect(resolved.ttlMs).toBe(0);
expect(resolved.maxCount).toBe(1);
expect(resolved.maxBytes).toBe(1_048_576);
});
it("reads the plugin's admin settings, and treats unset fields as unset", async () => {
const { ctx } = createFakeContext({
settings: {
imageStorageMode: "local",
imageLocalDir: "/persisted/images",
},
});
const resolved = await readImageStorageSettings(ctx);
expect(resolved.mode).toBe("local");
expect(resolved.localDir).toBe(localDir("/persisted/images"));
// Nothing stored and no environment: the built-in default.
expect(resolved.maxCount).toBe(DEFAULT_IMAGE_MAX_COUNT);
});
it("keeps TERMINAL_IMAGE_STORAGE_KEYS as the lookup names", () => {
expect(TERMINAL_IMAGE_STORAGE_KEYS.mode).toBe(
"terminal_image_storage_mode",
);
});
});
});
@@ -0,0 +1,571 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EventEmitter } from "events";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { randomUUID } from "crypto";
import {
REMOTE_IMAGE_DIR,
selectImageStorageMode,
storeImageLocally,
storeImageViaSftp,
TerminalImageStorageError,
type ImageSftpClient,
} from "../../../src/backend/images/image-storage.js";
import type { TerminalImageStorageSettings } from "../../../src/backend/images/image-storage-settings.js";
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
function settings(
overrides: Partial<TerminalImageStorageSettings>,
): TerminalImageStorageSettings {
return {
mode: "local",
localDir: "/nonexistent",
hostPath: "/tmp/termix-image-v0",
ttlMs: 3_600_000,
maxCount: 100,
maxBytes: 5_368_709_120,
localMappingConfigured: false,
...overrides,
};
}
describe("selectImageStorageMode", () => {
it("keeps explicit modes deterministic regardless of capability", () => {
expect(
selectImageStorageMode(settings({ mode: "local" }), {
remoteSftpAvailable: true,
}),
).toBe("local");
expect(
selectImageStorageMode(settings({ mode: "remote-sftp" }), {
remoteSftpAvailable: false,
}),
).toBe("remote-sftp");
});
it("falls back on capability only in auto mode", () => {
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: true,
}),
).toBe("remote-sftp");
expect(
selectImageStorageMode(settings({ mode: "auto" }), {
remoteSftpAvailable: false,
}),
).toBe("unavailable");
expect(
selectImageStorageMode(
settings({ mode: "auto", localMappingConfigured: true }),
{ remoteSftpAvailable: false, localHostVisible: true },
),
).toBe("local");
});
});
describe("storeImageLocally", () => {
let dir: string;
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-images-test-"));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});
async function seedFile(bytes: number, mtimeMs?: number): Promise<string> {
const name = `${randomUUID()}.png`;
const filePath = path.join(dir, name);
await fs.writeFile(filePath, Buffer.alloc(bytes));
if (mtimeMs !== undefined) {
const date = new Date(mtimeMs);
await fs.utimes(filePath, date, date);
}
return name;
}
it("writes a UUID-named PNG and returns the agent-visible host path", async () => {
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, hostPath: "/host-view/images" }),
);
expect(stored.storage).toBe("local");
expect(stored.filename).toBe(`${stored.id}.png`);
expect(stored.shellPath).toBe(
path.posix.join("/host-view/images", stored.filename),
);
expect(stored.shellPath).not.toContain(dir);
await expect(fs.readFile(path.join(dir, stored.filename))).resolves.toEqual(
PNG_BYTES,
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the count cap is full", async () => {
await seedFile(10);
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1 }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("rejects with IMAGE_STORAGE_LIMIT_REACHED when the byte cap is full", async () => {
await seedFile(900);
const error = await storeImageLocally(
Buffer.alloc(200),
settings({ localDir: dir, maxBytes: 1_000 }),
).catch((caught: unknown) => caught);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("cleans expired files during upload so they no longer count", async () => {
await seedFile(10, Date.now() - 2 * 3_600_000);
const stored = await storeImageLocally(
PNG_BYTES,
settings({ localDir: dir, maxCount: 1, ttlMs: 3_600_000 }),
);
expect(stored.storage).toBe("local");
const remaining = await fs.readdir(dir);
expect(remaining).toEqual([stored.filename]);
});
it("fails closed when local storage inspection is unavailable", async () => {
const blocked = path.join(dir, "blocked-file");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: blocked }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
it("reports inspection failures before attempting a write", async () => {
const blocked = path.join(dir, "blocked");
await fs.writeFile(blocked, "not a directory");
const error = await storeImageLocally(
PNG_BYTES,
settings({ localDir: path.join(blocked, "images") }),
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_LOCAL_INSPECTION_FAILED",
);
});
});
describe("storeImageViaSftp", () => {
function fakeSftp(behavior: {
mkdirError?: Error;
writeError?: Error;
stallWrite?: boolean;
stallLock?: boolean;
stallReaddir?: boolean;
stallRmdir?: boolean;
stallUnlink?: boolean;
readdirEntries?: Array<{ filename: string; mtime?: number; size?: number }>;
readdirError?: Error;
statMode?: number;
}): {
sftp: ImageSftpClient;
written: Map<string, Buffer>;
calls: {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
>;
} {
const written = new Map<string, Buffer>();
const streams: Array<
NodeJS.WritableStream & { destroy: () => void; destroyed: boolean }
> = [];
const calls = { mkdir: [], createWriteStream: [] } as {
mkdir: Array<{ dir: string; mode?: number }>;
createWriteStream: Array<{ path: string; mode?: number }>;
};
const sftp = {
mkdir: (
dir: string,
attrsOrCallback: { mode?: number } | ((err?: Error) => void),
maybeCallback?: (err?: Error) => void,
) => {
const callback =
typeof attrsOrCallback === "function"
? attrsOrCallback
: maybeCallback!;
calls.mkdir.push({
dir,
mode:
typeof attrsOrCallback === "function"
? undefined
: attrsOrCallback.mode,
});
if (
behavior.stallLock &&
dir === `${REMOTE_IMAGE_DIR}/.termix-write-lock`
) {
return;
}
callback(dir === REMOTE_IMAGE_DIR ? behavior.mkdirError : undefined);
},
stat: (
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) => callback(undefined, { mode: behavior.statMode ?? 0o40700 }),
chmod: (_dir: string, _mode: number, callback: (error?: Error) => void) =>
callback(),
readdir: (
_dir: string,
callback: (
error: Error | undefined,
entries: Array<{
filename: string;
attrs?: { mtime?: number; size?: number };
}>,
) => void,
) => {
if (behavior.stallReaddir) return;
callback(
behavior.readdirError,
behavior.readdirEntries?.map((entry) => ({
filename: entry.filename,
attrs: entry,
})) ?? [],
);
},
createWriteStream: (remotePath: string, options?: { mode?: number }) => {
calls.createWriteStream.push({ path: remotePath, mode: options?.mode });
const stream = new EventEmitter() as NodeJS.WritableStream & {
end: (data: Buffer) => void;
destroy: () => void;
destroyed: boolean;
};
stream.destroyed = false;
stream.destroy = () => {
stream.destroyed = true;
};
streams.push(stream);
stream.end = (data: Buffer) => {
if (behavior.stallWrite) return;
queueMicrotask(() => {
if (behavior.writeError) {
stream.emit("error", behavior.writeError);
return;
}
written.set(remotePath, data);
stream.emit("close");
});
};
return stream;
},
unlink: (_remotePath: string, callback: (error?: Error) => void) => {
if (behavior.stallUnlink) return;
callback();
},
rmdir: (_dir: string, callback: (error?: Error) => void) => {
if (behavior.stallRmdir) return;
callback();
},
} as unknown as ImageSftpClient;
return { sftp, written, calls, streams };
}
it("writes into the remote image directory and returns its POSIX path", async () => {
const { sftp, written } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(stored.shellPath).toBe(`${REMOTE_IMAGE_DIR}/${stored.id}.png`);
expect(written.get(stored.shellPath)).toEqual(PNG_BYTES);
});
it("requests restrictive modes for the remote directory and file", async () => {
const { sftp, calls } = fakeSftp({});
const stored = await storeImageViaSftp(sftp, PNG_BYTES);
expect(stored.storage).toBe("remote-sftp");
expect(calls.mkdir).toEqual([
{ dir: REMOTE_IMAGE_DIR, mode: 0o700 },
{ dir: `${REMOTE_IMAGE_DIR}/.termix-write-lock`, mode: 0o700 },
]);
expect(calls.createWriteStream).toEqual([
{ path: stored.shellPath, mode: 0o600 },
]);
});
it("removes expired UUID PNGs with best-effort remote retention", async () => {
const nowMs = 10_000_000;
const expiredName = `${randomUUID()}.png`;
const freshName = `${randomUUID()}.png`;
const unlinked: string[] = [];
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient & {
readdir: (
dir: string,
callback: (
err: Error | undefined,
entries: Array<{ filename: string; attrs?: { mtime?: number } }>,
) => void,
) => void;
unlink: (remotePath: string, callback: (err?: Error) => void) => void;
};
sftp.readdir = (_dir, callback) =>
callback(undefined, [
{ filename: expiredName, attrs: { mtime: (nowMs - 7_200_000) / 1000 } },
{ filename: freshName, attrs: { mtime: nowMs / 1000 } },
{ filename: "other.txt", attrs: { mtime: 0 } },
]);
sftp.unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 3_600_000,
nowMs,
});
expect(unlinked).toEqual([`${REMOTE_IMAGE_DIR}/${expiredName}`]);
});
it("fails closed when remote quota inspection fails", async () => {
const { sftp } = fakeSftp({
readdirError: new Error("remote listing failed"),
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_QUOTA_UNAVAILABLE",
);
});
it("rejects remote writes at the configured count limit", async () => {
const { sftp } = fakeSftp({
readdirEntries: [{ filename: `${randomUUID()}.png`, size: 12 }],
});
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 1,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_STORAGE_LIMIT_REACHED",
);
});
it("destroys a stalled SFTP write after its timeout", async () => {
const { sftp, streams } = fakeSftp({ stallWrite: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
writeTimeoutMs: 25,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect(streams[0]!.destroyed).toBe(true);
});
it("rejects an existing remote path that is not a directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
statMode: 0o100644,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("preserves SFTP client context for directory inspection", async () => {
const base = fakeSftp({ mkdirError: new Error("already exists") });
const sftp = base.sftp;
sftp.lstat = function (
this: ImageSftpClient,
_dir: string,
callback: (error: Error | undefined, attrs?: { mode?: number }) => void,
) {
if (this !== sftp) {
callback(new Error("SFTP context lost"));
return;
}
callback(undefined, { mode: 0o40700 });
};
const result = await storeImageViaSftp(sftp, PNG_BYTES);
expect(result.storage).toBe("remote-sftp");
});
it("tolerates mkdir failures for an already-existing directory", async () => {
const { sftp } = fakeSftp({
mkdirError: new Error("Failure: file already exists"),
});
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
});
it("cleans up a partially created remote file after a write failure", async () => {
const { sftp } = fakeSftp({ writeError: new Error("write failed") });
const unlinked: string[] = [];
(sftp as ImageSftpClient).unlink = (remotePath, callback) => {
unlinked.push(remotePath);
callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect(unlinked).toHaveLength(1);
expect(unlinked[0]).toMatch(
new RegExp(`${REMOTE_IMAGE_DIR}/[0-9a-f-]+\\.png`),
);
});
it("preserves the write error when partial-file cleanup stalls", async () => {
const { sftp } = fakeSftp({
writeError: new Error("write failed"),
stallUnlink: true,
});
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("recovers a stale remote lock before writing", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
let lockAttempt = 0;
const removed: string[] = [];
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => {
if (dir === lockPath && lockAttempt++ === 0) {
(typeof attrs === "function" ? attrs : callback!)(new Error("exists"));
return;
}
originalMkdir(dir, attrs, callback);
};
sftp.lstat = (_dir, callback) =>
callback(undefined, {
mode: 0o40700,
mtime: (Date.now() - 60_000) / 1000,
});
sftp.rmdir = (dir, callback) => {
removed.push(dir);
callback();
};
await expect(storeImageViaSftp(sftp, PNG_BYTES)).resolves.toMatchObject({
storage: "remote-sftp",
});
expect(removed).toContain(lockPath);
});
it("does not replace a successful write with an unlock failure", async () => {
const base = fakeSftp({});
const sftp = base.sftp as ImageSftpClient;
const lockPath = `${REMOTE_IMAGE_DIR}/.termix-write-lock`;
const originalMkdir = sftp.mkdir.bind(sftp);
sftp.mkdir = (dir, attrs, callback) => originalMkdir(dir, attrs, callback);
sftp.rmdir = (dir, callback) => {
if (dir === lockPath) callback(new Error("unlock failed"));
else callback();
};
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
});
it("fails closed when remote lock acquisition stalls", async () => {
const { sftp } = fakeSftp({ stallLock: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 12_000);
it("fails closed when remote quota inspection stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
maxCount: 10,
maxBytes: 1024,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote expiry cleanup stalls", async () => {
const { sftp } = fakeSftp({ stallReaddir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES, {
ttlMs: 1_000,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 7_000);
it("fails closed when remote lock release stalls", async () => {
const { sftp } = fakeSftp({ stallRmdir: true });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
}, 5_000);
it("maps SFTP failures to IMAGE_REMOTE_WRITE_FAILED", async () => {
const { sftp } = fakeSftp({ writeError: new Error("Permission denied") });
const error = await storeImageViaSftp(sftp, PNG_BYTES).catch(
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(TerminalImageStorageError);
expect((error as TerminalImageStorageError).code).toBe(
"IMAGE_REMOTE_WRITE_FAILED",
);
expect((error as TerminalImageStorageError).message).toBe(
"Failed to write image to the remote host",
);
});
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
exceedsImageStorageLimit,
exceedsNormalizedImageSize,
imageExtensionForFormat,
createConcurrencyLimiter,
isExpiredImage,
isImageFilename,
} from "../../../src/backend/images/image-utils.js";
describe("terminal image utilities", () => {
it("accepts UUID-based image filenames", () => {
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506.jpeg")).toBe(
true,
);
});
it("rejects unsafe or unrelated filenames", () => {
expect(isImageFilename("../secrets.txt")).toBe(false);
expect(isImageFilename("not-an-image.jpeg")).toBe(false);
expect(isImageFilename("b797234d-eb5d-4b1b-b7f3-17c26f257506")).toBe(false);
});
it("maps decoded raster formats while excluding SVG", () => {
expect(imageExtensionForFormat("jpeg")).toBe("jpg");
expect(imageExtensionForFormat("heif")).toBe("heif");
expect(imageExtensionForFormat("tiff")).toBe("tiff");
expect(imageExtensionForFormat("svg")).toBeUndefined();
expect(imageExtensionForFormat(undefined)).toBeUndefined();
});
it("rejects normalized output beyond the byte ceiling", () => {
expect(exceedsNormalizedImageSize(10_000_001, 10_000_000)).toBe(true);
expect(exceedsNormalizedImageSize(10_000_000, 10_000_000)).toBe(false);
});
it("expires files older than the configured TTL", () => {
expect(isExpiredImage(1_000, 3_000, 1_000)).toBe(true);
expect(isExpiredImage(2_500, 3_000, 1_000)).toBe(false);
});
it("treats a zero TTL as retention disabled", () => {
expect(isExpiredImage(1_000, 999_999, 0)).toBe(false);
});
it("bounds the admission queue", async () => {
const limiter = createConcurrencyLimiter(1, 0);
const release = await limiter.acquire();
await expect(limiter.acquire()).rejects.toThrow("queue is full");
release();
});
it("rejects uploads that exceed the count or byte limit", () => {
expect(exceedsImageStorageLimit(100, 10, 1, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 101, 100, 1000)).toBe(true);
expect(exceedsImageStorageLimit(1, 900, 100, 100, 1000)).toBe(false);
});
it("bounds concurrent work", async () => {
const limiter = createConcurrencyLimiter(1);
let active = 0;
let peak = 0;
const task = async () => {
const release = await limiter.acquire();
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 5));
active -= 1;
release();
};
await Promise.all([task(), task(), task()]);
expect(peak).toBe(1);
expect(limiter.active).toBe(0);
});
});
@@ -0,0 +1,292 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
PluginKeyboardInteractiveDecision,
PluginKeyboardInteractivePrompt,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { SSHAuthManager } from "../../src/backend/keyboard-prompt.js";
/**
* Stands in for core's classifier (ctx.ssh.classifyKeyboardInteractive),
* which core's own tests cover. These tests are about what the terminal does
* with each decision: the socket messages, the answers and the timeouts.
*/
const PASSWORD = /password/i;
const FORTI = /type\s+['"]?push['"]?/i;
const PUSH =
/choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
const TOTP =
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i;
function classify(
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
): PluginKeyboardInteractiveDecision {
if (/warpgate/i.test(round.name)) {
return {
kind: "warpgate",
url: round.instructions.match(/https?:\/\/\S+/)?.[0] ?? null,
securityKey: round.instructions.match(/Security key: (\S+)/)?.[1] ?? "",
instructions: round.instructions,
};
}
const texts = round.prompts.map((p) => p.prompt);
const isPush =
!texts.some((t) => FORTI.test(t)) && texts.some((t) => PUSH.test(t));
if (!isPush) {
const totp = round.prompts.findIndex((p) => TOTP.test(p.prompt));
if (totp !== -1) return { kind: "totp", promptIndex: totp };
}
const stored = !!host.password && host.authType !== "none";
const first = round.prompts.findIndex(
(p) => !(PASSWORD.test(p.prompt) && stored),
);
if (first === -1) {
return {
kind: "auto",
responses: round.prompts.map((p) =>
PASSWORD.test(p.prompt) ? String(host.password) : "",
),
};
}
return { kind: "input", promptIndex: first, isPush: PUSH.test(texts[first]) };
}
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
function createManager() {
const sent: Record<string, unknown>[] = [];
const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
const manager = new SSHAuthManager({
ssh: { classifyKeyboardInteractive: classify },
log,
userId: "user-1",
ws,
hostId: 1,
isKeyboardInteractive: false,
keyboardInteractiveResponded: false,
keyboardInteractiveFinish: null,
totpPromptSent: false,
warpgateAuthPromptSent: false,
totpTimeout: null,
warpgateAuthTimeout: null,
totpAttempts: 0,
});
return { manager, sent };
}
describe("SSHAuthManager.handleKeyboardInteractive", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("routes a TOTP verification prompt to the totp flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Verification code: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{ type: "totp_required", prompt: "Verification code: " },
]);
expect(finish).not.toHaveBeenCalled();
});
it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Choose [1] Push, or [2] TOTP: ",
echo: true,
},
]);
});
it("silently auto-answers a plain password prompt when a stored password exists", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Password: ", echo: false }],
finish,
{ username: "root", password: "hunter2", authType: "password" },
);
expect(finish).toHaveBeenCalledWith(["hunter2"]);
expect(sent).toEqual([]);
});
it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Password authentication required",
},
},
{
type: "password_required",
prompt: "Press enter to send Push request: ",
echo: true,
},
]);
manager.context.keyboardInteractiveFinish?.([""]);
expect(finish).toHaveBeenCalledWith([""]);
});
it("uses a longer timeout for push-style prompts than generic prompts", () => {
vi.useFakeTimers();
try {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[{ prompt: "Press enter to send Push request: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
vi.advanceTimersByTime(180001);
expect(sent.some((m) => m.type === "error")).toBe(false);
vi.advanceTimersByTime(120000);
expect(sent.some((m) => m.type === "error")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("routes a FortiToken prompt to the totp flow instead of the push-confirm flow", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"",
"",
"",
[
{
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
echo: true,
},
],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "TOTP verification required",
},
},
{
type: "totp_required",
prompt:
"Enter your Token or type 'push' to receive a push notification: ",
},
]);
manager.context.keyboardInteractiveFinish?.(["push"]);
expect(finish).toHaveBeenCalledWith(["push"]);
});
it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
const { manager, sent } = createManager();
const finish = vi.fn();
manager.handleKeyboardInteractive(
"Warpgate Authentication",
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
"",
[{ prompt: "Press enter once done: ", echo: true }],
finish,
{ username: "root", authType: "none" },
);
expect(sent).toEqual([
{
type: "connection_log",
data: {
stage: "auth",
level: "info",
message: "Warpgate authentication required",
},
},
{
type: "warpgate_auth_required",
url: "https://warpgate.example.com/auth",
securityKey: "AB12",
instructions:
"Visit https://warpgate.example.com/auth to continue. Security key: AB12",
},
]);
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { generateKeyPairSync } from "crypto";
import ssh2Pkg, { type ParsedKey } from "ssh2";
import { MemoryAgent } from "../../src/backend/helpers.js";
describe("MemoryAgent", () => {
it("serves identities and signatures over the agent protocol", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const parsed = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
);
expect(parsed).not.toBeInstanceOf(Error);
const agent = new MemoryAgent(parsed as ParsedKey);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) => {
if (error || !result)
reject(error ?? new Error("Missing agent stream"));
else resolve(result);
});
},
);
const client = new ssh2Pkg.AgentProtocol(true);
client.pipe(stream).pipe(client);
const identities = await new Promise<ParsedKey[]>((resolve, reject) => {
client.getIdentities((error, keys) => {
if (error || !keys) reject(error ?? new Error("Missing identities"));
else resolve(keys);
});
});
expect(identities).toHaveLength(1);
expect(identities[0].getPublicSSH()).toEqual(
(parsed as ParsedKey).getPublicSSH(),
);
const data = Buffer.from("forwarded-agent-test");
const signature = await new Promise<Buffer>((resolve, reject) => {
client.sign(identities[0], data, (error, result) => {
if (error || !result) reject(error ?? new Error("Missing signature"));
else resolve(result);
});
});
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
client.destroy();
stream.destroy();
});
});
describe("MemoryAgent unsupported extension framing", () => {
it.each([false, true])(
"can enumerate keys after rejecting session-bind (fragmented=%s)",
async (fragmented) => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const key = ssh2Pkg.utils.parseKey(
privateKey.export({ type: "pkcs1", format: "pem" }),
) as ParsedKey;
const agent = new MemoryAgent(key);
const stream = await new Promise<NodeJS.ReadWriteStream>(
(resolve, reject) => {
agent.getStream((error, result) =>
error || !result ? reject(error) : resolve(result),
);
},
);
const replies: Buffer[] = [];
let buffered = Buffer.alloc(0);
stream.on("data", (data: Buffer) => {
buffered = Buffer.concat([buffered, data]);
while (
buffered.length >= 4 &&
buffered.length >= buffered.readUInt32BE(0) + 4
) {
const end = buffered.readUInt32BE(0) + 4;
replies.push(buffered.subarray(4, end));
buffered = buffered.subarray(end);
}
});
const extension = Buffer.from("session-bind@openssh.com");
const request = Buffer.alloc(4 + 1 + 4 + extension.length);
request.writeUInt32BE(request.length - 4, 0);
request[4] = 27;
request.writeUInt32BE(extension.length, 5);
extension.copy(request, 9);
const identities = Buffer.from([0, 0, 0, 1, 11]);
try {
const requests = Buffer.concat([request, identities]);
if (fragmented) {
stream.write(requests.subarray(0, 7));
stream.write(requests.subarray(7));
} else stream.write(requests);
await vi.waitFor(() => expect(replies).toHaveLength(2));
expect(replies[0][0]).toBe(5); // Unsupported extension remains refused.
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
expect(replies[1].readUInt32BE(1)).toBe(1);
} finally {
stream.destroy();
}
},
);
});
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// command_history as core's SQLite bootstrap created it before 2.9.0, plus the
// index performance-indexes.ts added.
const LEGACY_DDL = `
CREATE TABLE command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
command TEXT NOT NULL,
executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX idx_command_history_user_host ON command_history (user_id, host_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexesOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting command_history", () => {
it("keeps every existing row and its index when the legacy table is there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite.exec(`
INSERT INTO command_history (user_id, host_id, command, executed_at)
VALUES ('user-1', 1, 'ls -la', '2026-01-01T00:00:00.000Z'),
('user-2', 2, 'uptime', '2026-01-02T00:00:00.000Z');
`);
},
});
expect(db.applied).toEqual(["0001_adopt_command_history"]);
expect(tableExists("command_history")).toBe(false);
expect(
db.sqlite
.prepare(
"SELECT user_id, host_id, command, executed_at FROM p_ssh_terminal_command_history ORDER BY id",
)
.all(),
).toEqual([
{
user_id: "user-1",
host_id: 1,
command: "ls -la",
executed_at: "2026-01-01T00:00:00.000Z",
},
{
user_id: "user-2",
host_id: 2,
command: "uptime",
executed_at: "2026-01-02T00:00:00.000Z",
},
]);
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
"idx_command_history_user_host",
]);
});
it("creates a fresh table when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("p_ssh_terminal_command_history")).toBe(true);
expect(indexesOn("p_ssh_terminal_command_history")).toEqual([
"idx_command_history_user_host",
]);
});
it("cascades when the user or the host is deleted", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
},
});
db.sqlite.exec(`
INSERT INTO p_ssh_terminal_command_history (user_id, host_id, command)
VALUES ('user-1', 1, 'a'), ('user-2', 1, 'b'), ('user-2', 2, 'c');
`);
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 2");
expect(
db.sqlite
.prepare("SELECT command FROM p_ssh_terminal_command_history")
.all(),
).toEqual([{ command: "b" }]);
});
});
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
const history = (s: TestServer) =>
s.db.sqlite
.prepare(
"SELECT user_id, host_id, command FROM p_ssh_terminal_command_history ORDER BY id",
)
.all();
describe("command history routes", () => {
it("saves, lists newest first without duplicates, deletes and clears", async () => {
server = await startServer();
for (const command of ["ls", "pwd", "ls"]) {
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1, command },
});
expect(saved.status).toBe(201);
}
await server.request("POST", "/command-history", {
user: "user-2",
body: { hostId: 1, command: "whoami" },
});
const listed = await server.request("GET", "/command-history/1");
expect(listed.body).toEqual(["ls", "pwd"]);
await server.request("POST", "/command-history/delete", {
body: { hostId: 1, command: "pwd" },
});
expect((await server.request("GET", "/command-history/1")).body).toEqual([
"ls",
]);
await server.request("DELETE", "/command-history/1");
expect(history(server)).toEqual([
{ user_id: "user-2", host_id: 1, command: "whoami" },
]);
expect(server.db.persisted).toBeGreaterThan(0);
});
it("acknowledges but never stores a command that looks like a secret", async () => {
server = await startServer();
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1, command: "export API_TOKEN=abc" },
});
expect(saved.status).toBe(201);
expect(history(server)).toEqual([]);
});
it("stores nothing while command history is off globally", async () => {
server = await startServer({ settings: { commandHistoryEnabled: false } });
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "ls" },
});
expect(history(server)).toEqual([]);
});
it("stores nothing for a host that turned command history off", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(2, "enableCommandHistory", false);
await server.request("POST", "/command-history", {
body: { hostId: 2, command: "ls" },
});
await server.request("POST", "/command-history", {
body: { hostId: 1, command: "ls" },
});
expect(history(server)).toEqual([
{ user_id: "user-1", host_id: 1, command: "ls" },
]);
});
it("rejects a request missing its fields", async () => {
server = await startServer();
const saved = await server.request("POST", "/command-history", {
body: { hostId: 1 },
});
expect(saved.status).toBe(400);
});
it("needs hosts.view for the recent list", async () => {
server = await startServer({ permissions: [] });
const denied = await server.request("GET", "/command-history/1/recent");
expect(denied.status).toBe(403);
});
it("lists recent commands with duplicates for a hosts.view holder", async () => {
server = await startServer({ permissions: ["hosts.view"] });
for (const command of ["uptime", "uptime"]) {
await server.request("POST", "/command-history", {
body: { hostId: 1, command },
});
}
const allowed = await server.request("GET", "/command-history/1/recent");
expect(allowed.body).toEqual(["uptime", "uptime"]);
});
});
describe("client settings and image storage", () => {
it("serves the terminal's settings to any signed-in user", async () => {
server = await startServer({
settings: { sessionTimeoutMinutes: 45, sessionPersistence: false },
});
const { body } = await server.request("GET", "/client-settings");
expect(body.sessionTimeoutMinutes).toBe(45);
expect(body.sessionPersistence).toBe(false);
expect(body.commandHistoryEnabled).toBe(true);
expect(body.touchInput.enabled).toBe(true);
});
it("refuses the image storage test to a non-admin", async () => {
server = await startServer({ permissions: [] });
const denied = await server.request("POST", "/image-storage/test", {
body: { instanceId: "tab-1" },
});
expect(denied.status).toBe(403);
});
it("reports an unconnected session to an admin", async () => {
server = await startServer({ permissions: ["admin.plugins.manage"] });
const result = await server.request("POST", "/image-storage/test", {
body: { instanceId: "tab-1" },
});
expect(result.status).toBe(200);
expect(result.body.connected).toBe(false);
});
});
@@ -0,0 +1,503 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
TerminalSessionManager,
isMessageAllowedForParticipant,
} from "../../src/backend/session-manager.js";
import type { RecordingSink } from "../../src/backend/services.js";
// A recordings.writer provider that keeps what it was given.
const appends = vi.fn<(chunk: string) => Promise<void>>();
const persists = vi.fn<RecordingSink["persist"]>();
const discards = vi.fn();
const opens = vi.fn();
const log = { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn() };
const sessionManager = new TerminalSessionManager({
log,
getTimeoutMinutes: () => 30,
getRecordings: () => ({
open: async (meta) => {
opens(meta);
return { append: appends, persist: persists, discard: discards };
},
}),
});
// Minimal fake WebSocket - only the surface session-manager touches.
function makeFakeWs(readyState = 1 /* OPEN */) {
return {
readyState,
send: vi.fn(),
close: vi.fn(),
terminate: vi.fn(),
} as unknown as import("ws").WebSocket;
}
const WS_OPEN = 1;
const WS_CLOSED = 3;
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
appends.mockResolvedValue(undefined);
persists.mockResolvedValue(undefined);
});
it("createSession stores sessionLoggingEnabled=true by default", () => {
const id = sessionManager.createSession("u1", 1, "host", 80, 24);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(true);
sessionManager.destroySession(id);
});
it("createSession stores sessionLoggingEnabled=false when passed", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const session = sessionManager.getSession(id);
expect(session?.sessionLoggingEnabled).toBe(false);
sessionManager.destroySession(id);
});
it("does not record when sessionLoggingEnabled=false", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
sessionManager.bufferOutput(id, "some output");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(opens).not.toHaveBeenCalled();
expect(appends).not.toHaveBeenCalled();
});
it("hands the recording to the recordings service in one batch, then persists it", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.bufferOutput(id, "terminal output data");
sessionManager.bufferOutput(id, "more output");
sessionManager.bufferInput(id, "ls\r");
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
// One write for the whole burst, never one per chunk (issue #1049).
expect(appends).toHaveBeenCalledOnce();
const chunk = appends.mock.calls[0][0];
expect(chunk.split("\n")[0]).toContain('"version":2');
expect(chunk).toContain("terminal output data");
expect(chunk).toContain("more output");
expect(persists).toHaveBeenCalledOnce();
expect(opens).toHaveBeenCalledWith(
expect.objectContaining({ hostId: 1, userId: "u1", protocol: "ssh" }),
);
});
it("does not write log file when buffer is empty", async () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
true,
);
sessionManager.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(appends).not.toHaveBeenCalled();
expect(discards).toHaveBeenCalledOnce();
});
it("records nothing when no recordings service is running", async () => {
const withoutRecordings = new TerminalSessionManager({
log,
getTimeoutMinutes: () => 30,
getRecordings: () => null,
});
const id = withoutRecordings.createSession("u1", 1, "host", 80, 24);
expect(withoutRecordings.getSession(id)?.sessionLoggingEnabled).toBe(false);
withoutRecordings.bufferOutput(id, "output");
withoutRecordings.destroySession(id);
await new Promise((r) => setTimeout(r, 20));
expect(appends).not.toHaveBeenCalled();
withoutRecordings.destroyAll();
});
it("bufferOutput trims old data when exceeding 512KB", () => {
const id = sessionManager.createSession(
"u1",
1,
"host",
80,
24,
undefined,
false,
);
const chunk = "x".repeat(300 * 1024);
sessionManager.bufferOutput(id, chunk);
sessionManager.bufferOutput(id, chunk);
const session = sessionManager.getSession(id);
expect(session!.outputBufferBytes).toBeLessThanOrEqual(512 * 1024);
sessionManager.destroySession(id);
});
});
describe("TerminalSessionManager - multiplayer participants", () => {
beforeEach(() => {
vi.clearAllMocks();
appends.mockResolvedValue(undefined);
persists.mockResolvedValue(undefined);
});
function createConnectedSession(): string {
const id = sessionManager.createSession(
"owner-1",
1,
"host",
80,
24,
undefined,
false,
);
// Mark connected without a real ssh2 stream - only isConnected is read
// by attachWs/joinAsParticipant.
const session = sessionManager.getSession(id)!;
session.isConnected = true;
return id;
}
it("joinAsParticipant adds a participant without evicting the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
guestLabel: "Guest",
});
expect(session).not.toBeNull();
expect(session!.participants.size).toBe(2);
const ownerParticipant = sessionManager.getParticipantForWs(
session!,
ownerWs,
);
expect(ownerParticipant?.isOwner).toBe(true);
// The join is announced to everyone already in the session - and that is
// the only unsolicited message the owner receives.
expect(ownerWs.send).toHaveBeenCalledTimes(1);
const announced = JSON.parse(
(ownerWs.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string,
);
expect(announced.type).toBe("participants");
expect(announced.participants).toHaveLength(2);
sessionManager.destroySession(id);
});
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const aliceWs = makeFakeWs();
const bobWs = makeFakeWs();
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, bobWs, {
userId: "bob",
permissionLevel: "read-only",
shareId: "stage-share",
});
sessionManager.setRoomShareControl(id, "stage-share", "alice");
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-write");
expect(
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
).toBe("read-only");
expect(
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
).toBe("read-write");
sessionManager.setRoomShareControl(id, "stage-share", null);
expect(
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
).toBe("read-only");
sessionManager.destroySession(id);
});
it("disconnectShareParticipants revokes only the selected share participants", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
const aliceWs = makeFakeWs();
const guestWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const session = sessionManager.joinAsParticipant(id, aliceWs, {
userId: "alice",
permissionLevel: "read-only",
shareId: "stage-share",
})!;
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
shareId: "stage-share",
});
expect(
sessionManager.disconnectShareParticipants(id, "stage-share", {
userId: "alice",
reason: "Removed",
}),
).toBe(1);
expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull();
expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull();
expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe(
true,
);
expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed");
sessionManager.destroySession(id);
});
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
expect(
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
userId: null,
permissionLevel: "read-only",
}),
).toBeNull();
});
it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs(WS_OPEN);
sessionManager.attachWs(id, "owner-1", ownerWs);
const openGuestWs = makeFakeWs(WS_OPEN);
const closedGuestWs = makeFakeWs(WS_CLOSED);
sessionManager.joinAsParticipant(id, openGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.joinAsParticipant(id, closedGuestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.broadcast(id, { type: "data", data: "hello" });
expect(ownerWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(openGuestWs.send).toHaveBeenCalledWith(
JSON.stringify({ type: "data", data: "hello" }),
);
expect(closedGuestWs.send).not.toHaveBeenCalled();
sessionManager.destroySession(id);
});
it("broadcast does not throw if a socket's send throws", () => {
const id = createConnectedSession();
const throwingWs = makeFakeWs(WS_OPEN);
(throwingWs.send as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("send failed");
});
sessionManager.attachWs(id, "owner-1", throwingWs);
expect(() =>
sessionManager.broadcast(id, { type: "data", data: "x" }),
).not.toThrow();
sessionManager.destroySession(id);
});
it("broadcast is a no-op for a nonexistent session", () => {
expect(() =>
sessionManager.broadcast("does-not-exist", { type: "data" }),
).not.toThrow();
});
it("owner detach arms the idle timeout (existing behavior)", () => {
vi.useFakeTimers();
try {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.detachWs(id);
const session = sessionManager.getSession(id);
expect(session?.detachTimeout).not.toBeNull();
expect(session?.lastDetachedAt).not.toBeNull();
sessionManager.destroySession(id);
} finally {
vi.useRealTimers();
}
});
it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.removeParticipant(id, guestWs);
const session = sessionManager.getSession(id);
expect(session).not.toBeNull();
expect(session?.detachTimeout).toBeNull();
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
sessionManager.destroySession(id);
});
it("removeParticipant is a no-op when the ws belongs to the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
sessionManager.removeParticipant(id, ownerWs);
const session = sessionManager.getSession(id);
expect(session?.participants.size).toBe(1);
expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
true,
);
sessionManager.destroySession(id);
});
it("destroySession cleans up all participants, not just the owner", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-only",
});
sessionManager.destroySession(id);
expect(guestWs.send).toHaveBeenCalled();
expect(sessionManager.getSession(id)).toBeNull();
});
it("ownerEndSession notifies non-owner participants and destroys the session", () => {
const id = createConnectedSession();
const ownerWs = makeFakeWs();
sessionManager.attachWs(id, "owner-1", ownerWs);
const guestWs = makeFakeWs();
sessionManager.joinAsParticipant(id, guestWs, {
userId: null,
permissionLevel: "read-write",
});
sessionManager.ownerEndSession(id, "owner ended the session");
expect(guestWs.send).toHaveBeenCalledWith(
JSON.stringify({
type: "sessionTerminatedByOwner",
reason: "owner ended the session",
}),
);
expect(sessionManager.getSession(id)).toBeNull();
});
});
describe("isMessageAllowedForParticipant", () => {
it("allows any message type for the owner or when there is no participant", () => {
expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: true, permissionLevel: "read-write" },
"resize",
),
).toBe(true);
});
it("drops input from a read-only participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"input",
),
).toBe(false);
});
it("allows input from a read-write non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
"input",
),
).toBe(true);
});
it("allows ping and disconnect for any non-owner participant", () => {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"ping",
),
).toBe(true);
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-only" },
"disconnect",
),
).toBe(true);
});
it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
for (const type of [
"resize",
"totp_response",
"password_response",
"tmux_attach",
"tmux_detach",
"get_cwd",
"vault_start_auth",
"opkssh_start_auth",
]) {
expect(
isMessageAllowedForParticipant(
{ isOwner: false, permissionLevel: "read-write" },
type,
),
).toBe(false);
}
});
});
@@ -58,10 +58,36 @@ describe(`${manifest.id} activate`, () => {
"terminal.listSessions",
"terminal.sendToActive",
"terminal.sendToSession",
"terminal.open",
]),
);
});
it("registers the command history panel and its rail item", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.panels()).toContain("history");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"history",
);
});
it("offers the local terminal only in the desktop app", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).not.toContain("local-terminal");
await rendered.deactivate();
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
try {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).toContain("local-terminal");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"local-terminal",
);
} finally {
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
}
});
it("terminal.sendToActive and terminal.listSessions report no session when none is registered", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
await expect(
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { dispatchKeybindingAction } from "../../../src/frontend/lib/keybinding-dispatch";
import type { Terminal } from "@xterm/xterm";
import type { KeybindingDispatchContext } from "../../../src/frontend/lib/keybinding-dispatch";
import * as sdkFrontend from "@termix/plugin-sdk/frontend";
vi.mock("@termix/plugin-sdk/frontend", () => ({ invokeAction: vi.fn() }));
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.mocked(sdkFrontend.invokeAction).mockResolvedValue({
needsInputs: false,
content: "uptime",
});
const ctx = makeContext();
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(sdkFrontend.invokeAction).toHaveBeenCalledWith(
"snippets.resolveForTerminal",
1,
null,
);
expect(ctx.sentData).toEqual(["uptime\r"]);
});
it("runSnippet does nothing when the snippet no longer exists", async () => {
vi.mocked(sdkFrontend.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.mocked(sdkFrontend.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(sdkFrontend.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.mocked(sdkFrontend.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",
});
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { getMacLineNavigationSequence } from "../../../src/frontend/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,70 @@
import { describe, expect, it } from "vitest";
import {
TerminalLocalEcho,
resolveLocalEchoMode,
} from "../../../src/frontend/lib/terminal-local-echo";
describe("TerminalLocalEcho", () => {
it("renders immediately and suppresses the matching remote echo", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("a")).toBe("a");
expect(echo.handleOutput("a")).toBe("");
});
it("rolls back a prediction when remote output differs", () => {
const echo = new TerminalLocalEcho("on");
echo.handleInput("a");
expect(echo.handleOutput("z")).toBe("\x1b[1D\x1b[1Xz");
});
it("only erases the predicted cells during rollback", () => {
const echo = new TerminalLocalEcho("on");
echo.handleInput("a");
echo.handleInput("b");
expect(echo.handleOutput("\x1b[C")).toBe("\x1b[2D\x1b[2X\x1b[C");
});
it.each([
["split prompt", ["Pass", "word: "]],
["sudo prompt", ["[sudo] password for alice: "]],
["ssh-keygen prompt", ["Enter passphrase (empty for no passphrase): "]],
["passwd prompt", ["New password: "]],
])("does not expose input after a %s", (_name, chunks) => {
const echo = new TerminalLocalEcho("on");
for (const chunk of chunks) expect(echo.handleOutput(chunk)).toBe(chunk);
expect(echo.handleInput("s")).toBe("");
});
it("does not treat ordinary password text as a hidden-input prompt", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleOutput("Your password has expired\r\n$ ")).toBe(
"Your password has expired\r\n$ ",
);
expect(echo.handleInput("s")).toBe("s");
});
it("does not predict control input, paste, or wide characters", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("\t")).toBe("");
expect(echo.handleInput("paste")).toBe("");
expect(echo.handleInput("界")).toBe("");
});
it("enables automatic prediction after repeated slow echoes", () => {
let now = 0;
const echo = new TerminalLocalEcho("auto", () => now, 100);
expect(echo.handleInput("a")).toBe("");
now = 150;
expect(echo.handleOutput("a")).toBe("a");
expect(echo.handleInput("b")).toBe("");
now = 300;
expect(echo.handleOutput("b")).toBe("b");
expect(echo.handleInput("c")).toBe("c");
});
it("uses an explicit host mode before the global mode", () => {
expect(resolveLocalEchoMode("off", "on")).toBe("off");
expect(resolveLocalEchoMode("default", "on")).toBe("on");
expect(resolveLocalEchoMode(undefined, null)).toBe("auto");
});
});
@@ -0,0 +1,441 @@
import { describe, it, expect } from "vitest";
import {
highlightTerminalOutput,
updateControlStringMode,
} from "../../../src/frontend/lib/terminal-syntax-highlighter";
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);
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TouchInputSettings } from "../../../src/frontend/settings/TouchInputSettings";
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
function renderSettings(values: Record<string, unknown> = {}) {
const setValue = vi.fn();
render(
<TouchInputSettings
pluginId="ssh-terminal"
values={values}
setValue={setValue}
running
/>,
);
return setValue;
}
describe("TouchInputSettings", () => {
it("shows simple defaults, keeps tunables collapsed, and resets", async () => {
const user = userEvent.setup();
const setValue = renderSettings({
touchInput: { ...TOUCH_INPUT_DEFAULTS, dragThresholdPx: 20 },
});
expect(screen.getByText("touchInput.enabled")).toBeTruthy();
expect(screen.queryByLabelText("touchInput.dragThreshold")).toBeNull();
await user.click(screen.getByText("touchInput.advanced"));
expect(
(screen.getByLabelText("touchInput.dragThreshold") as HTMLInputElement)
.value,
).toBe("20");
expect(
(
screen.getByLabelText(
"touchInput.maximumTicksPerFrame",
) as HTMLInputElement
).value,
).toBe("4");
await user.click(screen.getByText("touchInput.resetDefaults"));
expect(setValue).toHaveBeenLastCalledWith("touchInput", {
...TOUCH_INPUT_DEFAULTS,
});
});
it("falls back to the defaults when nothing is stored", () => {
renderSettings();
expect(screen.getByText("touchInput.momentumEnabled")).toBeTruthy();
});
});
@@ -0,0 +1,920 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StrictMode } from "react";
import type { Host } from "../../../src/frontend/types";
const mobileApi = vi.hoisted(() => ({
isMobile: false as boolean | undefined,
}));
const slots = vi.hoisted(() => ({
toolbar: [] as Array<{ actionId: string; titleKey: string }>,
invoked: [] as unknown[][],
}));
const labels: Record<string, string> = {
"terminalToolbar.detachTmux": "Detach tmux",
"terminalToolbar.detachTmuxDescription": "Detach tmux session",
"terminalToolbar.image": "Image actions (images only)",
"terminalToolbar.upload": "Upload image",
"terminalToolbar.uploadImage": "Upload an image to the host",
"terminalToolbar.uploadingImage": "Uploading image…",
"terminalToolbar.paste": "Paste image",
"terminalToolbar.pasteImage": "Paste image from clipboard (images only)",
"terminalToolbar.showActions": "Show toolbar",
"terminalToolbar.showToolbar": "Expand toolbar",
"terminalToolbar.hideToolbar": "Hide toolbar",
"terminalToolbar.layout": "Toolbar display mode",
"terminalToolbar.layoutIcon": "Icon only",
"terminalToolbar.layoutLabeled": "Icons and labels",
"terminalToolbar.layoutExpanded": "Expanded with metrics",
"terminalToolbar.moveToolbar": "Move toolbar",
"terminalToolbar.moveToolbarHint": "Drag to reposition the toolbar",
"terminalToolbar.imageActionFailed": "Image action failed",
"terminalToolbar.retry": "Retry",
"terminalToolbar.dismissError": "Dismiss image error",
"ai.assistant": "AI Assistant",
};
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({
t: (key: string) => labels[key] ?? key,
language: "en",
}),
useSlotContributions: (slotId: string) =>
slotId === "terminal.toolbar" ? slots.toolbar : [],
}));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useIsMobile: () => mobileApi.isMobile,
// The real slot reads the action registry; the toolbar only decides where
// it goes and what it is handed.
ActionSlot: ({
context,
enabled,
}: {
context: () => unknown[];
enabled?: boolean;
}) =>
enabled === false
? null
: slots.toolbar.map((contribution) => (
<button
key={contribution.actionId}
type="button"
onClick={() => slots.invoked.push(context())}
>
{labels[contribution.titleKey] ?? contribution.titleKey}
</button>
)),
ComponentSlot: () => null,
}));
import { TerminalToolbar } from "../../../src/frontend/terminal/TerminalToolbar";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
} from "../../../src/frontend/terminal/toolbar-geometry";
const host = { id: 7, connectionType: "ssh" } as unknown as Host;
function renderToolbar(
overrides: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) {
const props: React.ComponentProps<typeof TerminalToolbar> = {
host,
isConnected: true,
isTmuxAttached: true,
onTmuxDetach: vi.fn(),
isImageUploading: false,
onUploadImage: vi.fn(),
onPasteImage: vi.fn(),
isFocused: true,
...overrides,
};
return { ...render(<TerminalToolbar {...props} />), props };
}
// jsdom lacks the pointer APIs Radix Select needs to open its popup.
beforeEach(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.setPointerCapture ??= () => {};
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
vi.clearAllMocks();
mobileApi.isMobile = false;
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1024,
});
localStorage.clear();
slots.toolbar = [];
slots.invoked = [];
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("responsive toolbar density", () => {
it("enters below 110 percent and restores at 115 percent", () => {
expect(getResponsiveToolbarDensity("labeled", "labeled", 1099, 1000)).toBe(
"icon",
);
expect(getResponsiveToolbarDensity("labeled", "labeled", 1100, 1000)).toBe(
"labeled",
);
expect(getResponsiveToolbarDensity("labeled", "icon", 1149, 1000)).toBe(
"icon",
);
expect(getResponsiveToolbarDensity("labeled", "icon", 1150, 1000)).toBe(
"labeled",
);
});
it("never falls back when Icon is explicitly selected", () => {
expect(getResponsiveToolbarDensity("icon", "icon", 10, 1000)).toBe("icon");
expect(getResponsiveToolbarDensity("icon", "icon", 2000, 1000)).toBe(
"icon",
);
});
it("retains the prior effective state while measurement is unavailable", () => {
expect(getResponsiveToolbarDensity("expanded", "expanded", 0, 1000)).toBe(
"expanded",
);
expect(getResponsiveToolbarDensity("expanded", "icon", 0, 1000)).toBe(
"icon",
);
});
});
describe("TerminalToolbar Phase 1", () => {
it("clamps vertical movement and preserves over-tall recovery", () => {
const hostRect = {
left: 0,
right: 400,
top: 0,
bottom: 200,
width: 400,
height: 200,
};
const toolbarRect = {
left: 100,
right: 300,
top: 140,
bottom: 190,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: 0, y: -999 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}).y,
).toBe(-132);
expect(
clampToolbarPosition({ x: 0, y: 999 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}).y,
).toBe(2);
expect(
clampToolbarPosition(
{ x: 0, y: -999 },
{ ...toolbarRect, top: 150, bottom: 350, height: 200 },
hostRect,
{ x: 0, y: 0 },
).y,
).toBe(-298);
});
it("keeps horizontal outputs unchanged during simultaneous movement", () => {
const hostRect = {
left: 100,
right: 500,
top: 50,
bottom: 350,
width: 400,
height: 300,
};
const toolbarRect = {
left: 250,
right: 450,
top: 280,
bottom: 330,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: -999, y: -100 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}),
).toEqual({ x: -142, y: -100 });
expect(
clampToolbarPosition({ x: 999, y: 12 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}),
).toEqual({ x: 42, y: 12 });
});
it("fills the full terminal surface while remaining bottom-centered and pointer transparent", () => {
const { container } = renderToolbar();
const toolbarHost = container.querySelector("[data-terminal-toolbar-host]");
const toolbar = container.querySelector("[data-terminal-toolbar-wide]");
expect(toolbarHost).toHaveClass(
"absolute",
"inset-0",
"flex",
"items-end",
"justify-center",
"pointer-events-none",
"pb-2",
);
expect(toolbarHost).not.toHaveClass("inset-x-2", "bottom-2");
expect(toolbar).toHaveClass("pointer-events-auto");
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
});
it("clamps every edge and preserves recovery for an over-wide toolbar", () => {
const hostRect = {
left: 100,
right: 500,
top: 50,
bottom: 350,
width: 400,
height: 300,
};
const toolbar = {
left: 250,
right: 450,
top: 280,
bottom: 330,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: -500, y: 0 }, toolbar, hostRect, {
x: 0,
y: 0,
}),
).toEqual({
x: -142,
y: 0,
});
expect(
clampToolbarPosition({ x: 500, y: 0 }, toolbar, hostRect, { x: 0, y: 0 }),
).toEqual({
x: 42,
y: 0,
});
expect(
clampToolbarPosition({ x: 0, y: -500 }, toolbar, hostRect, {
x: 0,
y: 0,
}),
).toEqual({
x: 0,
y: -222,
});
expect(
clampToolbarPosition({ x: 0, y: 500 }, toolbar, hostRect, { x: 0, y: 0 }),
).toEqual({
x: 0,
y: 12,
});
expect(
clampToolbarPosition(
{ x: -999, y: 0 },
{ ...toolbar, left: -100, right: 600, width: 700 },
hostRect,
{ x: 0, y: 0 },
).x,
).toBe(-448);
});
it("clamps an off-screen restored position to actual host bounds", async () => {
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 9000, y: -9000 }),
);
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
const x = Number(match?.[1] ?? 0);
const y = Number(match?.[2] ?? 0);
return DOMRect.fromRect({
x: 250 + x,
y: 250 + y,
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const { container } = renderToolbar();
await waitFor(() =>
expect(
(container.querySelector("[data-terminal-toolbar-wide]") as HTMLElement)
.style.transform,
).toBe("translate(0px, 0px)"),
);
});
it("renders the PC toolbar and Image group with adjacent actions", () => {
const { container } = renderToolbar();
expect(container.querySelector("[data-terminal-toolbar-host]")).toHaveClass(
"@container",
);
expect(container.querySelector("[data-terminal-toolbar-wide]")).toHaveClass(
"pointer-events-auto",
);
expect(
container.querySelector("[data-terminal-toolbar-narrow]"),
).toBeNull();
const imageGroup = screen.getAllByRole("group", {
name: "Image actions (images only)",
})[0];
expect(imageGroup.querySelectorAll("button, label")).toHaveLength(2);
expect(
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
).toBeVisible();
expect(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
).toBeVisible();
});
it("uses explicit labels, conditional tmux, and compact interactive targets", () => {
const { rerender, props } = renderToolbar();
expect(
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
).toHaveTextContent("Detach tmux");
expect(
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
).toHaveClass("min-h-8", "min-w-8");
rerender(<TerminalToolbar {...props} isTmuxAttached={false} />);
expect(
screen.queryByRole("button", { name: "Detach tmux session" }),
).not.toBeInTheDocument();
});
it("exposes density as a select and applies the chosen mode", async () => {
const user = userEvent.setup();
renderToolbar();
const trigger = screen.getByRole("combobox", {
name: "Toolbar display mode",
});
expect(trigger).toHaveTextContent("");
await user.click(trigger);
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Icon only",
"Icons and labels",
"Expanded with metrics",
]);
await user.click(
screen.getByRole("option", { name: "Expanded with metrics" }),
);
await waitFor(() =>
expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe(
"expanded",
),
);
});
it("keeps one file input mounted, resets repeated selections, and exposes loading", () => {
const onUploadImage = vi.fn();
const { container, rerender, props } = renderToolbar({ onUploadImage });
const input = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(1);
const file = new File(["image"], "same.png", { type: "image/png" });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.change(input, { target: { files: [file] } });
expect(onUploadImage).toHaveBeenCalledTimes(2);
rerender(<TerminalToolbar {...props} isImageUploading />);
expect(container.querySelector('input[type="file"]')).toBe(input);
expect(screen.getByRole("status")).toHaveTextContent("Uploading image…");
expect(
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
).toBeDisabled();
expect(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
).toBeDisabled();
});
it("hides clearly, leaves recovery, and closes open UI on disconnect", async () => {
const user = userEvent.setup();
const { rerender, props } = renderToolbar();
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
const collapsedMover = screen.getByRole("button", {
name: "Move toolbar",
});
expect(collapsedMover).toHaveAttribute("aria-label", "Move toolbar");
await user.click(screen.getByRole("button", { name: "Expand toolbar" }));
expect(
screen.getByRole("combobox", { name: "Toolbar display mode" }),
).toBeInTheDocument();
rerender(<TerminalToolbar {...props} isConnected={false} />);
expect(
screen.queryByRole("combobox", { name: "Toolbar display mode" }),
).not.toBeInTheDocument();
});
it("calls paste synchronously in the activation handler and does not cancel outside pointerdown", () => {
const onPasteImage = vi.fn();
renderToolbar({ onPasteImage });
const paste = screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0];
fireEvent.click(paste);
expect(onPasteImage).toHaveBeenCalledOnce();
const outside = new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(outside);
expect(outside.defaultPrevented).toBe(false);
});
it("shows an actionable image error and retries the failed activation", async () => {
const user = userEvent.setup();
const onPasteImage = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("Clipboard permission denied"))
.mockResolvedValueOnce();
renderToolbar({ onPasteImage });
await user.click(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Clipboard permission denied",
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(onPasteImage).toHaveBeenCalledTimes(2);
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
});
it("routes synchronous image failures through the alert flow", async () => {
renderToolbar({
onPasteImage: vi.fn(() => {
throw new Error("Synchronous clipboard failure");
}),
});
fireEvent.click(
screen.getByRole("button", {
name: "Paste image from clipboard (images only)",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Synchronous clipboard failure",
);
});
it("orders desktop actions and keeps the grab handle persistent", () => {
const { container } = renderToolbar();
const labels = Array.from(
container.querySelectorAll("[data-terminal-toolbar-wide] button"),
).map((button) => button.getAttribute("aria-label"));
expect(labels).toEqual([
"Detach tmux session",
"Upload an image to the host",
"Paste image from clipboard (images only)",
"Toolbar display mode",
"Hide toolbar",
"Move toolbar",
]);
});
it("shows three modes and fades the expanded toolbar", async () => {
const user = userEvent.setup();
const { container } = renderToolbar();
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
expect(toolbar).toHaveClass(
"opacity-30",
"transition-opacity",
"hover:opacity-100",
"focus-within:opacity-100",
);
await user.click(
screen.getByRole("combobox", { name: "Toolbar display mode" }),
);
expect(await screen.findAllByRole("option")).toHaveLength(3);
});
it("gives the collapsed toolbar idle opacity and keyboard recovery", async () => {
const user = userEvent.setup();
const { container } = renderToolbar();
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
expect(toolbar).toHaveClass(
"opacity-100",
"hover:opacity-100",
"focus-within:opacity-100",
);
expect(
screen.getByRole("button", { name: "Expand toolbar" }),
).toBeVisible();
const collapsedMover = screen.getByRole("button", {
name: "Move toolbar",
});
expect(collapsedMover).toBeVisible();
expect(
container.querySelector(".terminal-toolbar-collapsed-shell"),
).toBeInTheDocument();
collapsedMover.focus();
expect(toolbar).toHaveClass("focus-within:opacity-100");
const expandButton = screen.getByRole("button", {
name: "Expand toolbar",
});
expandButton.focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Hide toolbar" }),
).toHaveFocus(),
);
}, 15_000);
it("keeps the mover on the right edge across collapse and expansion", async () => {
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
const collapsed = !this.querySelector(
'button[aria-label="Hide toolbar"]',
);
return DOMRect.fromRect({
x: 225 + Number(match?.[1] ?? 0),
y: 290 + Number(match?.[2] ?? 0),
width: collapsed ? 44 : 250,
height: collapsed ? 44 : 50,
});
}
return original.call(this);
},
);
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 117, y: 2 }),
);
const { container } = renderToolbar();
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Hide toolbar" }));
expect(toolbar.style.transform).toBe("translate(323px, 2px)");
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Expand toolbar" }));
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
});
it("moves and persists the toolbar position, then stops after pointerup", () => {
const { container, unmount } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 1,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(grab, {
pointerId: 1,
clientX: 140,
clientY: 90,
});
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
fireEvent.pointerUp(grab, { pointerId: 1 });
expect(
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
).toEqual({ x: 40, y: -10 });
fireEvent.pointerMove(grab, {
pointerId: 1,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
unmount();
const second = renderToolbar();
expect(
(
second.container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement
).style.transform,
).toBe("translate(40px, -10px)");
});
it("moves on both axes, clamps safely, ignores a different pointer ID, and persists", () => {
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
return DOMRect.fromRect({
x: 225 + Number(match?.[1] ?? 0),
y: 290 + Number(match?.[2] ?? 0),
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const { container } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 11,
button: 0,
clientX: 10,
clientY: 100,
});
fireEvent.pointerMove(grab, {
pointerId: 12,
clientX: 30,
clientY: -500,
});
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 30,
clientY: 120,
});
expect(toolbar.style.transform).toBe("translate(20px, 2px)");
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
fireEvent.pointerUp(grab, { pointerId: 11 });
expect(
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
).toEqual({ x: 117, y: 2 });
});
it("avoids ResizeObserver write churn and writes one real reclamp", async () => {
let resizeCallback: ResizeObserverCallback | undefined;
class ControlledResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
disconnect() {}
unobserve() {}
}
vi.stubGlobal("ResizeObserver", ControlledResizeObserver);
let hostWidth = 500;
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({
x: 0,
y: 0,
width: hostWidth,
height: 300,
});
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
return DOMRect.fromRect({
x: 125 + Number(match?.[1] ?? 0),
y: 242 + Number(match?.[2] ?? 0),
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const writes = vi.spyOn(Storage.prototype, "setItem");
renderToolbar();
writes.mockClear();
act(() => {
resizeCallback?.([], {} as ResizeObserver);
resizeCallback?.([], {} as ResizeObserver);
});
expect(writes).not.toHaveBeenCalledWith(
"termix-terminal-toolbar-position-v2",
expect.any(String),
);
hostWidth = 200;
act(() => resizeCallback?.([], {} as ResizeObserver));
expect(writes).not.toHaveBeenCalledWith(
"termix-terminal-toolbar-position-v2",
expect.any(String),
);
expect(
writes.mock.calls.filter(
([key]) => key === "termix-terminal-toolbar-position-v2",
),
).toHaveLength(0);
});
it.each([undefined, true])(
"does not mount or run desktop side effects when mobile is %s",
(isMobile) => {
mobileApi.isMobile = isMobile;
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
const writes = vi.spyOn(Storage.prototype, "setItem");
const addWindowListener = vi.spyOn(window, "addEventListener");
const resizeObserver = vi.fn();
vi.stubGlobal("ResizeObserver", resizeObserver);
const { container } = renderToolbar();
expect(
container.querySelector("[data-terminal-toolbar-host]"),
).not.toBeInTheDocument();
expect(writes).not.toHaveBeenCalled();
expect(resizeObserver).not.toHaveBeenCalled();
expect(addWindowListener).not.toHaveBeenCalledWith(
"resize",
expect.any(Function),
);
},
);
it("ends dragging on pointer cancellation and lost capture, and rejects touch", () => {
const { container } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 2,
pointerType: "touch",
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(grab, { pointerId: 2, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
fireEvent.pointerDown(grab, {
pointerId: 3,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerCancel(grab, { pointerId: 3 });
fireEvent.pointerMove(grab, { pointerId: 3, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
fireEvent.pointerDown(grab, {
pointerId: 4,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.lostPointerCapture(grab, { pointerId: 4 });
fireEvent.pointerMove(grab, { pointerId: 4, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
});
it("ignores stale image completions after an instance change in Strict Mode", async () => {
let reject!: (error: Error) => void;
const pending = new Promise<void>((_, rejectPromise) => {
reject = rejectPromise;
});
const props = {
host,
isConnected: true,
isTmuxAttached: false,
onTmuxDetach: vi.fn(),
isImageUploading: false,
onUploadImage: vi.fn(),
onPasteImage: vi.fn(() => pending),
isFocused: true,
};
const { rerender } = render(
<StrictMode>
<TerminalToolbar {...props} />
</StrictMode>,
);
fireEvent.click(
screen.getByRole("button", {
name: "Paste image from clipboard (images only)",
}),
);
rerender(
<StrictMode>
<TerminalToolbar
{...props}
host={{ ...host, id: 8 } as unknown as Host}
/>
</StrictMode>,
);
reject(new Error("old instance"));
await Promise.resolve();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
describe("contributed toolbar actions", () => {
it("shows no contributed button and no stray separator when nothing contributed", () => {
const { container } = renderToolbar();
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
const separatorsWithout = container.querySelectorAll(".w-px").length;
cleanup();
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
const withButton = renderToolbar().container;
expect(withButton.querySelectorAll(".w-px").length).toBe(
separatorsWithout + 1,
);
});
it("hands the action the terminal slot api", async () => {
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
const slotApi = {
host: undefined,
getBufferText: vi.fn(() => "$ whoami\nroot"),
sessionId: () => null,
openSidePanel: vi.fn(),
runCommand: vi.fn(),
};
renderToolbar({ slotApi });
await userEvent.click(
screen.getByRole("button", { name: "AI Assistant" }),
);
expect(slots.invoked).toEqual([[slotApi]]);
});
it("hides the slot when the host has contributed actions disabled", () => {
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
renderToolbar({ actionsEnabled: false });
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
});
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getAndroidHardwareKeySequence } from "../../../src/frontend/terminal/android-hardware-keyboard";
const key = (
value: string,
modifiers: Partial<KeyboardEvent> = {},
): Pick<
KeyboardEvent,
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
> => ({
key: value,
ctrlKey: false,
altKey: false,
metaKey: false,
shiftKey: false,
...modifiers,
});
describe("getAndroidHardwareKeySequence", () => {
it("maps cursor keys in normal and application modes", () => {
expect(
getAndroidHardwareKeySequence(key("ArrowUp"), false, undefined),
).toBe("\x1b[A");
expect(getAndroidHardwareKeySequence(key("ArrowUp"), true, undefined)).toBe(
"\x1bOA",
);
});
it("maps Delete and the default Backspace mode", () => {
expect(getAndroidHardwareKeySequence(key("Delete"), false, undefined)).toBe(
"\x1b[3~",
);
expect(
getAndroidHardwareKeySequence(key("Backspace"), false, undefined),
).toBe("\x7f");
});
it("leaves control-h Backspace and modified keys to existing handlers", () => {
expect(
getAndroidHardwareKeySequence(key("Backspace"), false, "control-h"),
).toBeNull();
expect(
getAndroidHardwareKeySequence(
key("ArrowLeft", { ctrlKey: true }),
false,
undefined,
),
).toBeNull();
});
});
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { Terminal } from "@xterm/xterm";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Android IME composition", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
});
it("forwards a shorter Vietnamese replacement to the shell", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
textarea.value = "Hoar";
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
textarea.dispatchEvent(new CompositionEvent("compositionstart"));
textarea.dispatchEvent(
new CompositionEvent("compositionupdate", { data: "Hoar" }),
);
await tick();
textarea.value = "Hỏa";
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
textarea.dispatchEvent(
new CompositionEvent("compositionupdate", { data: "Hỏa" }),
);
await tick();
textarea.dispatchEvent(new CompositionEvent("compositionend"));
await tick();
expect(input.join("")).toBe("\x7f\x7f\x7fỏa");
});
it("coalesces rapid textarea changes without dropping or duplicating input", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
const type = (value: string) => {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
);
textarea.value = value;
textarea.selectionStart = value.length;
textarea.selectionEnd = value.length;
};
type("t");
type("te");
type("ter");
type("termix");
await tick();
expect(input.join("")).toBe("termix");
});
});
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
fontSizeStorageKey,
readFontSize,
saveFontSize,
} from "../../../src/frontend/terminal/font-size-storage";
beforeEach(() => localStorage.clear());
describe("persistent terminal zoom", () => {
it("survives repeated mounts and option refreshes", () => {
const key = fontSizeStorageKey("host-sync-id");
saveFontSize(localStorage, key, 14, 18);
for (let mount = 0; mount < 3; mount++)
expect(readFontSize(localStorage, key, 14)).toBe(18);
});
it("invalidates an override when defaults change while unmounted", () => {
const key = fontSizeStorageKey(1);
saveFontSize(localStorage, key, 14, 18);
expect(readFontSize(localStorage, key, 16)).toBeNull();
expect(readFontSize(localStorage, key, 14)).toBeNull();
});
it("does not share storage for missing host identities", () => {
const key = fontSizeStorageKey(undefined);
saveFontSize(localStorage, key, 14, 18);
expect(readFontSize(localStorage, key, 14)).toBeNull();
expect(localStorage.length).toBe(0);
});
it("ignores legacy values without a known configured size", () => {
localStorage.setItem("legacy", "18");
expect(readFontSize(localStorage, "legacy", 14)).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import { Terminal } from "@xterm/xterm";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
textarea.value = value;
textarea.selectionStart = value.length;
textarea.selectionEnd = value.length;
}
// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard
// input, not just true IME composition, so xterm routes typing through
// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the
// normal keypress path. That handler snapshots the textarea value on
// keydown, then diffs it against the value a setTimeout(0) later.
function dispatchIOSKeydown(textarea: HTMLTextAreaElement) {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
);
}
describe("iOS rapid typing (keyCode 229 outside composition)", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
});
it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
// keydown fires while the textarea still holds the pre-keystroke value;
// the browser (or, on iOS, autocorrect) mutates the value afterward.
// Autocorrect can rewrite characters earlier in the word, not just
// append at the cursor, so the old value is no longer a literal
// substring of the new one.
dispatchIOSKeydown(textarea);
setTextareaValue(textarea, "wrold");
await tick();
dispatchIOSKeydown(textarea);
setTextareaValue(textarea, "world");
await tick();
expect(input.join("")).toBe("wrold" + "orld");
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { getTerminalBufferText } from "../../../src/frontend/terminal/terminal-buffer-text";
interface FakeLine {
text: string;
wrapped?: boolean;
}
/** Minimal stand-in for the parts of xterm this reads. */
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
return {
rows,
buffer: {
active: {
baseY: Math.max(0, lines.length - rows),
getLine: (i: number) => {
const line = lines[i];
if (!line) return undefined;
return {
isWrapped: !!line.wrapped,
translateToString: () => line.text,
};
},
},
},
} as unknown as Terminal;
}
describe("getTerminalBufferText", () => {
it("returns an empty string for a missing terminal", () => {
expect(getTerminalBufferText(null)).toBe("");
expect(getTerminalBufferText(undefined)).toBe("");
});
it("joins buffer rows with newlines", () => {
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
});
it("joins a wrapped row onto the line it continues", () => {
const terminal = fakeTerminal([
{ text: "$ echo start" },
{ text: "a-very-long-command " },
{ text: "that-wrapped", wrapped: true },
]);
expect(getTerminalBufferText(terminal)).toBe(
"$ echo start\na-very-long-command that-wrapped",
);
});
it("drops trailing blank lines", () => {
const terminal = fakeTerminal([
{ text: "output" },
{ text: "" },
{ text: "" },
]);
expect(getTerminalBufferText(terminal)).toBe("output");
});
it("keeps blank lines in the middle", () => {
const terminal = fakeTerminal([
{ text: "one" },
{ text: "" },
{ text: "two" },
]);
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
});
it("bounds the result to maxLines, keeping the tail", () => {
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
const terminal = fakeTerminal(lines, 50);
const result = getTerminalBufferText(terminal, 3);
expect(result).toBe("line 47\nline 48\nline 49");
});
it("returns an empty string for a non-positive maxLines", () => {
const terminal = fakeTerminal([{ text: "anything" }]);
expect(getTerminalBufferText(terminal, 0)).toBe("");
});
it("returns an empty string when the buffer throws", () => {
const disposed = {
rows: 1,
get buffer(): never {
throw new Error("terminal disposed");
},
} as unknown as Terminal;
expect(getTerminalBufferText(disposed)).toBe("");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
resolveTerminalContextMenuAction,
selectedTextToCopy,
} from "../../../src/frontend/terminal/terminal-clipboard-actions";
describe("terminal clipboard actions", () => {
it("copies a completed left-button selection when enabled", () => {
expect(
selectedTextToCopy({
copyOnSelect: true,
button: 0,
selection: "selected output",
}),
).toBe("selected output");
expect(
selectedTextToCopy({ copyOnSelect: false, button: 0, selection: "x" }),
).toBeNull();
});
it("pastes on right-click after copy-on-select", () => {
expect(
resolveTerminalContextMenuAction({
rightClickCopyPaste: true,
copyOnSelect: true,
hasSelection: true,
}),
).toBe("paste");
});
it("preserves existing right-click and native-menu behavior", () => {
expect(
resolveTerminalContextMenuAction({
rightClickCopyPaste: true,
copyOnSelect: false,
hasSelection: true,
}),
).toBe("copy");
expect(
resolveTerminalContextMenuAction({
rightClickCopyPaste: false,
copyOnSelect: true,
hasSelection: true,
}),
).toBe("native");
});
});
@@ -0,0 +1,332 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Terminal } from "@xterm/xterm";
const getCookieMock = vi.fn<(name: string) => string | undefined>();
vi.mock("@termix/plugin-sdk/ui", () => ({
getCookie: (name: string) => getCookieMock(name),
}));
import {
getUseRightClickCopyPaste,
handleTerminalClipboardKeyEvent,
createTerminalContextMenuHandler,
} from "../../../src/frontend/terminal/terminal-clipboard";
function createFakeTerminal({ selection = "" }: { selection?: string } = {}) {
return {
hasSelection: vi.fn(() => selection.length > 0),
getSelection: vi.fn(() => selection),
clearSelection: vi.fn(),
paste: vi.fn(),
};
}
function createActions() {
return {
writeTextToClipboard: vi.fn().mockResolvedValue(true),
readTextFromClipboard: vi.fn().mockResolvedValue("clipboard-text"),
};
}
function createKeyEvent(init: {
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
key?: string;
code?: string;
}) {
return {
type: "keydown",
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
key: "",
code: "",
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...init,
} as unknown as KeyboardEvent;
}
function createMouseEvent(init: { ctrlKey?: boolean } = {}) {
return {
ctrlKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...init,
} as unknown as MouseEvent;
}
beforeEach(() => {
getCookieMock.mockReset();
getCookieMock.mockReturnValue(undefined);
});
describe("getUseRightClickCopyPaste", () => {
it("defaults to enabled when the cookie is unset", () => {
getCookieMock.mockReturnValue(undefined);
expect(getUseRightClickCopyPaste()).toBe(true);
});
it('is disabled only when the cookie is explicitly "false"', () => {
getCookieMock.mockReturnValue("false");
expect(getUseRightClickCopyPaste()).toBe(false);
getCookieMock.mockReturnValue("true");
expect(getUseRightClickCopyPaste()).toBe(true);
});
});
describe("handleTerminalClipboardKeyEvent", () => {
it("copies the selection on Ctrl+C and clears it", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(e.preventDefault).toHaveBeenCalled();
expect(e.stopPropagation).toHaveBeenCalled();
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).toHaveBeenCalled();
});
it("leaves Ctrl+C alone (SIGINT) when there is no selection", () => {
const terminal = createFakeTerminal({ selection: "" });
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(true);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("copies via Ctrl+Insert without clearing the selection", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, key: "Insert" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).not.toHaveBeenCalled();
});
it("copies and clears the selection on Ctrl+Shift+C", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const e = createKeyEvent({
ctrlKey: true,
shiftKey: true,
code: "KeyC",
key: "C",
});
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).toHaveBeenCalled();
});
it("is a no-op for Ctrl+Shift+C without a selection", () => {
const terminal = createFakeTerminal({ selection: "" });
const actions = createActions();
const e = createKeyEvent({
ctrlKey: true,
shiftKey: true,
code: "KeyC",
key: "C",
});
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(true);
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("always pastes on Ctrl+Shift+V regardless of selection", async () => {
const terminal = createFakeTerminal({ selection: "irrelevant" });
const actions = createActions();
const e = createKeyEvent({
ctrlKey: true,
shiftKey: true,
code: "KeyV",
key: "V",
});
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
await Promise.resolve();
expect(handled).toBe(false);
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.readTextFromClipboard).toHaveBeenCalled();
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
});
it("leaves plain Ctrl+V to the browser's native paste event by default", () => {
const terminal = createFakeTerminal();
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(e.stopPropagation).not.toHaveBeenCalled();
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
});
it("reads the clipboard explicitly for plain Ctrl+V when configured", async () => {
const terminal = createFakeTerminal();
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
{ plainPasteMode: "explicit" },
);
await Promise.resolve();
expect(handled).toBe(false);
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.readTextFromClipboard).toHaveBeenCalled();
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
});
it("ignores keys with no clipboard shortcut", () => {
const terminal = createFakeTerminal();
const actions = createActions();
const e = createKeyEvent({ key: "a", code: "KeyA" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(true);
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
});
});
describe("createTerminalContextMenuHandler", () => {
it("routes Ctrl+right-click to the passthrough callback instead of the clipboard", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const onCtrlClick = vi.fn();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
{ onCtrlClick },
);
const e = createMouseEvent({ ctrlKey: true });
handler(e);
expect(onCtrlClick).toHaveBeenCalledTimes(1);
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("falls back to normal right-click behavior when Ctrl is held with no passthrough configured", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent({ ctrlKey: true });
handler(e);
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
});
it("does nothing when the right-click preference is disabled", () => {
getCookieMock.mockReturnValue("false");
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent();
handler(e);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("copies the current selection when right-click copy/paste is enabled", async () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent();
handler(e);
await Promise.resolve();
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).toHaveBeenCalled();
});
it("pastes when there is no selection", async () => {
const terminal = createFakeTerminal({ selection: "" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent();
handler(e);
await Promise.resolve();
expect(actions.readTextFromClipboard).toHaveBeenCalled();
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { quoteTerminalImagePath } from "../../../src/frontend/terminal/terminal-image-path";
describe("quoteTerminalImagePath", () => {
it("preserves spaces as one shell argument", () => {
expect(quoteTerminalImagePath("/mnt/my images/clipboard.png")).toBe(
"'/mnt/my images/clipboard.png'",
);
});
it("does not expose shell metacharacters as syntax", () => {
expect(
quoteTerminalImagePath("/tmp/$(touch /tmp/pwned);`id`; 'quoted'.png"),
).toBe("'/tmp/$(touch /tmp/pwned);`id`; '\\''quoted'\\''.png'");
});
it("quotes an empty path instead of returning executable input", () => {
expect(quoteTerminalImagePath("")).toBe("''");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { buildImageUploadFormData } from "../../../src/frontend/terminal/terminal-image-upload";
describe("buildImageUploadFormData", () => {
it("tags a picked-file upload with source=file and a client timestamp", () => {
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
const form = buildImageUploadFormData(file, "tab-1", "file");
expect(form.get("image")).toBe(file);
expect(form.get("instanceId")).toBe("tab-1");
expect(form.get("source")).toBe("file");
const timestamp = form.get("clientUploadTimestamp");
expect(typeof timestamp).toBe("string");
expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);
});
it("tags a clipboard upload with source=clipboard", () => {
const file = new File(["png-bytes"], "clipboard-image.png", {
type: "image/png",
});
const form = buildImageUploadFormData(file, "tab-1", "clipboard");
expect(form.get("source")).toBe("clipboard");
});
it("uses the caller-provided client timestamp unchanged", () => {
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
const form = buildImageUploadFormData(
file,
"tab-1",
"file",
"2026-08-15T12:00:00.000Z",
);
expect(form.get("clientUploadTimestamp")).toBe("2026-08-15T12:00:00.000Z");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
isPhysicalShortcutKey,
isTabKeyEvent,
} from "../../../src/frontend/terminal/terminal-key-event";
describe("isTabKeyEvent", () => {
it.each([
["key", new KeyboardEvent("keydown", { key: "Tab" })],
["code", new KeyboardEvent("keydown", { code: "Tab" })],
[
"legacy keyCode",
new KeyboardEvent("keydown", { keyCode: 9 } as KeyboardEventInit),
],
])("recognizes Tab from %s", (_source, event) => {
expect(isTabKeyEvent(event)).toBe(true);
});
it("does not treat another key as Tab", () => {
expect(isTabKeyEvent(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(
false,
);
});
});
describe("isPhysicalShortcutKey", () => {
it("matches copy and paste by physical code under a Cyrillic layout", () => {
expect(isPhysicalShortcutKey({ key: "с", code: "KeyC" }, "KeyC", "c")).toBe(
true,
);
expect(isPhysicalShortcutKey({ key: "м", code: "KeyV" }, "KeyV", "v")).toBe(
true,
);
});
it("does not confuse another physical key with a translated character", () => {
expect(isPhysicalShortcutKey({ key: "c", code: "KeyV" }, "KeyC", "c")).toBe(
false,
);
});
it("falls back to key when code is unavailable", () => {
expect(isPhysicalShortcutKey({ key: "C", code: "" }, "KeyC", "c")).toBe(
true,
);
});
});
@@ -0,0 +1,417 @@
import { describe, expect, it, vi } from "vitest";
import {
installTouchWheelCoordinator,
TouchWheelCoordinator,
} from "../../../src/frontend/terminal/touch-wheel-coordinator";
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
const point = (clientY: number, clientX = 10) => ({ clientX, clientY });
const touchEvent = (type: string, clientYValues: number[]) => {
const event = new Event(type, { bubbles: true, cancelable: true });
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
Object.defineProperty(event, "touches", {
value: Object.assign(touches, {
item: (index: number) => touches[index] ?? null,
}),
});
return event;
};
const createTiming = (momentumAllowed = true) => {
let now = 0;
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
timing: {
now: () => now,
requestFrame: (callback: FrameRequestCallback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
},
cancelFrame: (handle: number) => callbacks.delete(handle),
momentumAllowed: () => momentumAllowed,
},
advance(milliseconds: number) {
now += milliseconds;
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((callback) => callback(now));
},
pendingFrames: () => callbacks.size,
};
};
describe("TouchWheelCoordinator", () => {
it("consumes configured drag and wheel limits", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit, 2, 3, undefined, {
...TOUCH_INPUT_DEFAULTS,
dragThresholdPx: 2,
maxWheelDeltaPx: 3,
});
coordinator.start(point(20));
coordinator.move(point(10));
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([
3, 3, 3, 1,
]);
});
it("accumulates the drag threshold and preserves delta direction", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
expect(coordinator.move(point(97.5))).toBe(true);
expect(coordinator.move(point(93))).toBe(true);
coordinator.move(point(101));
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([7, -8]);
});
it("keeps movement below the tap threshold from scrolling", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
expect(coordinator.move(point(95))).toBe(true);
coordinator.end();
expect(emit).not.toHaveBeenCalled();
});
it("cancels the gesture when more than one touch is represented", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
coordinator.move(undefined);
expect(coordinator.move(point(80))).toBe(false);
expect(emit).not.toHaveBeenCalled();
});
it("cleans up cancelled state before the next gesture", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
coordinator.cancel();
coordinator.start(point(50));
expect(coordinator.move(point(43.5))).toBe(true);
expect(emit).toHaveBeenCalledOnce();
expect(emit.mock.calls[0][0].deltaY).toBe(6.5);
});
it.each([
{ start: 100, end: 20, direction: 1 },
{ start: 20, end: 100, direction: -1 },
])(
"emits multiple $direction line ticks after release",
({ start, end, direction }) => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(start));
clock.advance(20);
coordinator.move(point((start + end) / 2));
clock.advance(20);
coordinator.move(point(end));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
for (let index = 0; index < 12; index++) clock.advance(16);
const momentum = emit.mock.calls
.slice(dragCallCount)
.map(([event]) => event);
expect(momentum.length).toBeGreaterThan(3);
expect(
momentum.every(
(event) =>
event.deltaMode === WheelEvent.DOM_DELTA_LINE &&
event.deltaY === direction,
),
).toBe(true);
},
);
it("decays the number of line ticks emitted over time", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(200));
clock.advance(20);
coordinator.move(point(100));
coordinator.end();
const dragCalls = emit.mock.calls.length;
const frameCounts: number[] = [];
for (let index = 0; index < 24; index++) {
const before = emit.mock.calls.length;
clock.advance(16);
frameCounts.push(emit.mock.calls.length - before);
}
expect(emit.mock.calls.length - dragCalls).toBeGreaterThan(3);
expect(frameCounts.slice(0, 8).reduce((a, b) => a + b, 0)).toBeGreaterThan(
frameCounts.slice(-8).reduce((a, b) => a + b, 0),
);
});
it("uses the recent movement window despite a short touchend delay", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
clock.advance(80);
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
});
it("bounds momentum velocity, duration, and total travel", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(1000));
clock.advance(1);
coordinator.move(point(0));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
clock.advance(200);
const firstFrameTicks = emit.mock.calls.length - dragCallCount;
for (let index = 0; index < 30; index++) clock.advance(20);
expect(clock.pendingFrames()).toBe(1);
for (let index = 0; index < 10; index++) clock.advance(20);
const totalTicks = emit.mock.calls.length - dragCallCount;
expect(firstFrameTicks).toBeLessThanOrEqual(4);
expect(totalTicks).toBeLessThanOrEqual(Math.floor(720 / 12));
expect(clock.pendingFrames()).toBe(0);
});
it("cancels momentum on a new touch or cancellation", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
coordinator.start(point(60));
expect(clock.pendingFrames()).toBe(0);
clock.advance(20);
coordinator.move(point(20));
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
coordinator.cancel();
expect(clock.pendingFrames()).toBe(0);
});
it("disables momentum when reduced motion is requested", () => {
const emit = vi.fn();
const clock = createTiming(false);
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
expect(clock.pendingFrames()).toBe(0);
expect(emit).toHaveBeenCalledOnce();
});
it("stops momentum if the active terminal mode changes", () => {
const emit = vi.fn();
let momentumAllowed = true;
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(emit, undefined, undefined, {
...clock.timing,
momentumAllowed: () => momentumAllowed,
});
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
momentumAllowed = false;
clock.advance(20);
expect(emit).toHaveBeenCalledOnce();
expect(clock.pendingFrames()).toBe(0);
});
it("retains fractional pixel distance until it forms a whole line tick", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(100);
coordinator.move(point(80));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
for (let index = 0; index < 4; index++) clock.advance(16);
expect(emit.mock.calls.length).toBe(dragCallCount);
clock.advance(16);
expect(emit.mock.calls.length).toBeGreaterThan(dragCallCount);
expect(emit.mock.calls[dragCallCount][0]).toMatchObject({
deltaY: 1,
deltaMode: WheelEvent.DOM_DELTA_LINE,
});
});
});
describe("installTouchWheelCoordinator", () => {
it("claims the first single-touch move but preserves taps and multi-touch", () => {
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
screenElement.className = "xterm-screen";
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const dispose = installTouchWheelCoordinator(terminalElement);
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
const earlyMove = touchEvent("touchmove", [99]);
terminalElement.dispatchEvent(earlyMove);
expect(earlyMove.defaultPrevented).toBe(true);
terminalElement.dispatchEvent(touchEvent("touchend", []));
const tapEnd = touchEvent("touchend", []);
terminalElement.dispatchEvent(tapEnd);
expect(tapEnd.defaultPrevented).toBe(false);
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
const multiTouchMove = touchEvent("touchmove", [90, 80]);
terminalElement.dispatchEvent(multiTouchMove);
expect(multiTouchMove.defaultPrevented).toBe(false);
dispose();
});
it("dispatches wheel events at xterm's normal scrollback seam", () => {
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
const canvas = document.createElement("canvas");
screenElement.className = "xterm-screen";
screenElement.append(canvas);
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const scrollableWheel = vi.fn();
const canvasWheel = vi.fn();
scrollableElement.addEventListener("wheel", scrollableWheel);
canvas.addEventListener("wheel", canvasWheel);
const dispose = installTouchWheelCoordinator(terminalElement);
canvas.dispatchEvent(touchEvent("touchstart", [100]));
canvas.dispatchEvent(touchEvent("touchmove", [90]));
expect(scrollableWheel).toHaveBeenCalledOnce();
expect(canvasWheel).not.toHaveBeenCalled();
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaY).toBe(10);
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaMode).toBe(
WheelEvent.DOM_DELTA_PIXEL,
);
dispose();
});
it("dispatches momentum as line-mode wheel ticks from the scrollback seam", () => {
let now = 0;
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
callbacks.delete(handle);
});
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
const canvas = document.createElement("canvas");
screenElement.className = "xterm-screen";
screenElement.append(canvas);
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const wheels: WheelEvent[] = [];
scrollableElement.addEventListener("wheel", (event) =>
wheels.push(event as WheelEvent),
);
const dispose = installTouchWheelCoordinator(terminalElement);
canvas.dispatchEvent(touchEvent("touchstart", [100]));
now = 20;
canvas.dispatchEvent(touchEvent("touchmove", [40]));
canvas.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((callback) => callback(now));
}
const momentum = wheels.filter(
(event) => event.deltaMode === WheelEvent.DOM_DELTA_LINE,
);
expect(momentum.length).toBeGreaterThan(1);
expect(momentum.every((event) => event.deltaY === 1)).toBe(true);
expect(wheels.every((event) => event.currentTarget === null)).toBe(true);
dispose();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
});
@@ -0,0 +1,140 @@
import { Terminal } from "@xterm/xterm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installTouchWheelCoordinator } from "../../../src/frontend/terminal/touch-wheel-coordinator";
const touchEvent = (type: string, clientYValues: number[]) => {
const event = new Event(type, { bubbles: true, cancelable: true });
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
Object.defineProperty(event, "touches", {
value: Object.assign(touches, {
item: (index: number) => touches[index] ?? null,
}),
});
return event;
};
const write = (terminal: Terminal, data: string) =>
new Promise<void>((resolve) => terminal.write(data, resolve));
describe("touch wheel coordinator with xterm", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
vi.unstubAllGlobals();
});
const installXtermCharacterMeasurements = () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
clearRect: vi.fn(),
fillRect: vi.fn(),
fillStyle: "",
lineWidth: 1,
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
function (this: HTMLElement) {
return this.classList.contains("xterm-char-measure-element")
? 32 * 9
: 0;
},
);
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
function (this: HTMLElement) {
return this.classList.contains("xterm-char-measure-element") ? 18 : 0;
},
);
};
it("moves real normal-buffer scrollback during a drag and momentum", async () => {
installXtermCharacterMeasurements();
let now = 0;
let nextHandle = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
frames.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
frames.delete(handle);
});
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal({ cols: 80, rows: 10, scrollback: 1_000 });
terminal.open(container);
await write(
terminal,
Array.from({ length: 500 }, (_, index) => `${index + 1}\r\n`).join(""),
);
const element = terminal.element!;
const dispose = installTouchWheelCoordinator(element);
terminal.scrollToBottom();
const bottom = terminal.buffer.active.viewportY;
element.dispatchEvent(touchEvent("touchstart", [20]));
now = 20;
element.dispatchEvent(touchEvent("touchmove", [100]));
const afterDrag = terminal.buffer.active.viewportY;
element.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...frames.values()];
frames.clear();
pending.forEach((callback) => callback(now));
}
expect(bottom).toBeGreaterThan(400);
expect(afterDrag).toBeLessThan(bottom);
expect(terminal.buffer.active.viewportY).toBeLessThan(afterDrag);
dispose();
});
it("preserves alternate-buffer wheel line ticks", async () => {
installXtermCharacterMeasurements();
let now = 0;
let nextHandle = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
frames.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
frames.delete(handle);
});
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal({ cols: 80, rows: 10 });
terminal.open(container);
const data: string[] = [];
terminal.onData((value) => data.push(value));
await write(terminal, "\x1b[?1049h");
const element = terminal.element!;
const dispose = installTouchWheelCoordinator(element);
element.dispatchEvent(touchEvent("touchstart", [100]));
now = 20;
element.dispatchEvent(touchEvent("touchmove", [0]));
data.length = 0;
element.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...frames.values()];
frames.clear();
pending.forEach((callback) => callback(now));
}
expect(terminal.buffer.active.type).toBe("alternate");
expect(data.length).toBeGreaterThan(1);
expect(data.every((value) => value === "\x1b[B")).toBe(true);
dispose();
});
});