mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user