mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: convert tmux monitoring into a plugin (B10)
This commit is contained in:
1 parent
79d77678c9
commit
708eedb861
145 files changed
+30308
-4458
No files matched your search
@@ -0,0 +1,120 @@
|
||||
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 { PluginHostSummary } from "@termix/plugin-sdk/backend";
|
||||
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 function host(
|
||||
overrides: Partial<PluginHostSummary> = {},
|
||||
): PluginHostSummary {
|
||||
return {
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
name: "web",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: null,
|
||||
folder: null,
|
||||
authType: "password",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
hosts?: PluginHostSummary[];
|
||||
/** hostId -> enableTmuxMonitor, set through ctx.settings before activate. */
|
||||
enabledHosts?: number[];
|
||||
sshClient?: unknown;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
for (const h of options.hosts ?? [host()]) {
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(h.id);
|
||||
}
|
||||
|
||||
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 ?? ["tmux-monitor.use"],
|
||||
hosts: options.hosts ?? [host()],
|
||||
sshClient: options.sshClient,
|
||||
});
|
||||
|
||||
for (const hostId of options.enabledHosts ?? [7]) {
|
||||
await mock.ctx.settings.setHost(hostId, "enableTmuxMonitor", true);
|
||||
}
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
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() {
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// tmux_session_tags as core's SQLite bootstrap created it before 2.9.0.
|
||||
// Core never indexed it.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE tmux_session_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_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
|
||||
);
|
||||
`;
|
||||
|
||||
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);
|
||||
|
||||
describe("adopting tmux_session_tags", () => {
|
||||
it("keeps every existing row 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 (7)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO tmux_session_tags (user_id, host_id, session_name, tag) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 7, "work", "lab");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_tmux_session_tags"]);
|
||||
expect(tableExists("tmux_session_tags")).toBe(false);
|
||||
expect(tableExists("p_tmux_monitor_session_tags")).toBe(true);
|
||||
|
||||
expect(
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, host_id, session_name, tag FROM p_tmux_monitor_session_tags",
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ user_id: "user-1", host_id: 7, session_name: "work", tag: "lab" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the table fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("tmux_session_tags")).toBe(false);
|
||||
expect(tableExists("p_tmux_monitor_session_tags")).toBe(true);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_tmux_monitor_session_tags").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("cascades on host delete", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (7)");
|
||||
},
|
||||
});
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_tmux_monitor_session_tags (user_id, host_id, session_name, tag) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 7, "work", "lab");
|
||||
|
||||
db.sqlite.exec("DELETE FROM ssh_data WHERE id = 7");
|
||||
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_tmux_monitor_session_tags").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SEP,
|
||||
parseSessions,
|
||||
parseWindows,
|
||||
parsePanes,
|
||||
parsePsOutput,
|
||||
parseGpuOutput,
|
||||
buildPaneMetrics,
|
||||
attachPanesToWindows,
|
||||
shellEscape,
|
||||
type ProcessInfo,
|
||||
type TmuxWindow,
|
||||
} from "../../src/backend/monitor-helpers.js";
|
||||
|
||||
function join(...fields: (string | number)[]): string {
|
||||
return fields.join(SEP);
|
||||
}
|
||||
|
||||
describe("parseSessions", () => {
|
||||
it("parses tmux list-sessions output", () => {
|
||||
const output = [
|
||||
join("training", 1760000000, 1760001000, 1),
|
||||
join("lab|with|pipes", 1760000500, 1760002000, 0),
|
||||
].join("\n");
|
||||
|
||||
const sessions = parseSessions(output);
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toEqual({
|
||||
name: "training",
|
||||
created: 1760000000,
|
||||
lastActivity: 1760001000,
|
||||
attachedClients: 1,
|
||||
});
|
||||
// Session names containing "|" survive because SEP is a multi-char token
|
||||
expect(sessions[1].name).toBe("lab|with|pipes");
|
||||
expect(sessions[1].attachedClients).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty output", () => {
|
||||
expect(parseSessions("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseWindows", () => {
|
||||
it("groups windows by session", () => {
|
||||
const output = [
|
||||
join("training", 0, 1, "vim"),
|
||||
join("training", 1, 0, "logs"),
|
||||
join("api", 0, 1, "server"),
|
||||
].join("\n");
|
||||
|
||||
const windows = parseWindows(output);
|
||||
expect(windows.get("training")).toHaveLength(2);
|
||||
expect(windows.get("training")![0]).toMatchObject({
|
||||
index: 0,
|
||||
name: "vim",
|
||||
active: true,
|
||||
});
|
||||
expect(windows.get("api")![0].name).toBe("server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePanes", () => {
|
||||
it("parses full pane lines including free-text fields", () => {
|
||||
const output = join(
|
||||
"training",
|
||||
0,
|
||||
"%3",
|
||||
1,
|
||||
12345,
|
||||
1,
|
||||
120,
|
||||
40,
|
||||
"python",
|
||||
"/home/user/my|dir",
|
||||
"gpu01: train.py",
|
||||
);
|
||||
|
||||
const panes = parsePanes(output);
|
||||
expect(panes).toHaveLength(1);
|
||||
expect(panes[0]).toEqual({
|
||||
sessionName: "training",
|
||||
windowIndex: 0,
|
||||
id: "%3",
|
||||
index: 1,
|
||||
pid: 12345,
|
||||
active: true,
|
||||
width: 120,
|
||||
height: 40,
|
||||
command: "python",
|
||||
path: "/home/user/my|dir",
|
||||
title: "gpu01: train.py",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePsOutput", () => {
|
||||
it("parses ps -eo pid,ppid,pcpu,pmem,rss,comm output", () => {
|
||||
const output = [
|
||||
" 1 0 0.0 0.1 1234 systemd",
|
||||
"12345 1 2.5 1.0 50000 bash",
|
||||
"12400 12345 95.3 12.5 800000 python3",
|
||||
"garbage line",
|
||||
].join("\n");
|
||||
|
||||
const procs = parsePsOutput(output);
|
||||
expect(procs).toHaveLength(3);
|
||||
expect(procs[2]).toEqual({
|
||||
pid: 12400,
|
||||
ppid: 12345,
|
||||
cpu: 95.3,
|
||||
mem: 12.5,
|
||||
rss: 800000,
|
||||
comm: "python3",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGpuOutput", () => {
|
||||
it("parses nvidia-smi csv output and sums per pid", () => {
|
||||
const output = ["12400, 8000", "12400, 2000", "99999, 512"].join("\n");
|
||||
const gpu = parseGpuOutput(output);
|
||||
expect(gpu.get(12400)).toBe(10000);
|
||||
expect(gpu.get(99999)).toBe(512);
|
||||
});
|
||||
|
||||
it("handles empty output (no GPU)", () => {
|
||||
expect(parseGpuOutput("").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPaneMetrics", () => {
|
||||
const panes = parsePanes(
|
||||
[
|
||||
join("training", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||
join("idle", 0, "%2", 0, 200, 1, 80, 24, "bash", "/", "t"),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const processes = parsePsOutput(
|
||||
[
|
||||
// pane %1: bash(100) -> python3(110) -> worker(111)
|
||||
" 100 1 0.1 0.1 4000 bash",
|
||||
" 110 100 90.0 10.0 700000 python3",
|
||||
" 111 110 9.5 2.0 100000 dataloader",
|
||||
// pane %2: bash(200) only
|
||||
" 200 1 0.0 0.1 4000 bash",
|
||||
// unrelated process
|
||||
" 300 1 50.0 5.0 200000 chrome",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
it("aggregates descendant trees per pane", () => {
|
||||
const metrics = buildPaneMetrics(panes, processes, new Map());
|
||||
const m1 = metrics.find((m) => m.paneId === "%1")!;
|
||||
expect(m1.processCount).toBe(3);
|
||||
expect(m1.cpuPercent).toBeCloseTo(99.6, 1);
|
||||
expect(m1.memRssKb).toBe(804000);
|
||||
expect(m1.topCommand).toBe("python3");
|
||||
|
||||
const m2 = metrics.find((m) => m.paneId === "%2")!;
|
||||
expect(m2.processCount).toBe(1);
|
||||
expect(m2.cpuPercent).toBe(0);
|
||||
// Unrelated process is never attributed
|
||||
expect(m2.memRssKb).toBe(4000);
|
||||
});
|
||||
|
||||
it("attributes GPU memory through the process tree", () => {
|
||||
const gpu = new Map([
|
||||
[110, 8000],
|
||||
[300, 4000],
|
||||
]);
|
||||
const metrics = buildPaneMetrics(panes, processes, gpu);
|
||||
expect(metrics.find((m) => m.paneId === "%1")!.gpuMemMb).toBe(8000);
|
||||
expect(metrics.find((m) => m.paneId === "%2")!.gpuMemMb).toBe(0);
|
||||
});
|
||||
|
||||
it("handles a pane whose pid is missing from ps output", () => {
|
||||
const orphan = parsePanes(
|
||||
join("gone", 0, "%9", 0, 99999, 0, 80, 24, "bash", "/", "t"),
|
||||
);
|
||||
const metrics = buildPaneMetrics(orphan, processes, new Map());
|
||||
expect(metrics[0].processCount).toBe(0);
|
||||
expect(metrics[0].cpuPercent).toBe(0);
|
||||
expect(metrics[0].topCommand).toBeNull();
|
||||
});
|
||||
|
||||
it("does not loop on cyclic ppid data", () => {
|
||||
const cyclic = parsePsOutput(
|
||||
[" 100 101 1.0 0.1 1000 a", " 101 100 1.0 0.1 1000 b"].join("\n"),
|
||||
);
|
||||
const pane = parsePanes(
|
||||
join("s", 0, "%1", 0, 100, 1, 80, 24, "a", "/", "t"),
|
||||
);
|
||||
const metrics = buildPaneMetrics(pane, cyclic, new Map());
|
||||
expect(metrics[0].processCount).toBe(2);
|
||||
});
|
||||
|
||||
it("aggregates a wide process tree without dropping children", () => {
|
||||
const childCount = 2_000;
|
||||
const wideTree: ProcessInfo[] = [
|
||||
{ pid: 1, ppid: 0, cpu: 0, mem: 0, rss: 1, comm: "bash" },
|
||||
...Array.from({ length: childCount }, (_, index) => ({
|
||||
pid: index + 2,
|
||||
ppid: 1,
|
||||
cpu: 0.1,
|
||||
mem: 0,
|
||||
rss: 1,
|
||||
comm: `worker-${index}`,
|
||||
})),
|
||||
];
|
||||
const pane = parsePanes(
|
||||
join("wide", 0, "%1", 0, 1, 1, 80, 24, "bash", "/", "t"),
|
||||
);
|
||||
|
||||
const [metrics] = buildPaneMetrics(pane, wideTree, new Map());
|
||||
expect(metrics.processCount).toBe(childCount + 1);
|
||||
expect(metrics.memRssKb).toBe(childCount + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachPanesToWindows", () => {
|
||||
it("places panes into their windows", () => {
|
||||
const windows = parseWindows(
|
||||
[join("s1", 0, 1, "main"), join("s1", 1, 0, "logs")].join("\n"),
|
||||
);
|
||||
const panes = parsePanes(
|
||||
[
|
||||
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||
join("s1", 1, "%2", 0, 200, 0, 80, 24, "tail", "/", "t"),
|
||||
join("unknown", 5, "%3", 0, 300, 0, 80, 24, "bash", "/", "t"),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
attachPanesToWindows(windows, panes);
|
||||
expect(windows.get("s1")![0].panes).toHaveLength(1);
|
||||
expect(windows.get("s1")![0].panes[0].id).toBe("%1");
|
||||
expect(windows.get("s1")![1].panes[0].id).toBe("%2");
|
||||
});
|
||||
|
||||
it("preserves first-match behavior for duplicate window indexes", () => {
|
||||
const first: TmuxWindow = {
|
||||
index: 0,
|
||||
name: "first",
|
||||
active: true,
|
||||
panes: [],
|
||||
};
|
||||
const duplicate: TmuxWindow = {
|
||||
index: 0,
|
||||
name: "duplicate",
|
||||
active: false,
|
||||
panes: [],
|
||||
};
|
||||
const windows = new Map([["s1", [first, duplicate]]]);
|
||||
const panes = parsePanes(
|
||||
join("s1", 0, "%1", 0, 100, 1, 80, 24, "bash", "/", "t"),
|
||||
);
|
||||
|
||||
attachPanesToWindows(windows, panes);
|
||||
expect(first.panes).toHaveLength(1);
|
||||
expect(duplicate.panes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shellEscape", () => {
|
||||
it("wraps in single quotes and escapes embedded quotes", () => {
|
||||
expect(shellEscape("simple")).toBe("'simple'");
|
||||
expect(shellEscape("it's")).toBe("'it'\\''s'");
|
||||
expect(shellEscape("$(rm -rf /)")).toBe("'$(rm -rf /)'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { sessionTags } from "../../src/backend/tables.js";
|
||||
import { createSessionTagRepository } from "../../src/backend/repository.js";
|
||||
import { manifest, pluginDir } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
async function createRepository() {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1'), ('user-2', 'user-2')",
|
||||
);
|
||||
db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
});
|
||||
const table = await mock.ctx.db.define(sessionTags);
|
||||
const repository = createSessionTagRepository(mock.ctx.db, table);
|
||||
|
||||
db.sqlite.exec(`
|
||||
INSERT INTO p_tmux_monitor_session_tags (user_id, host_id, session_name, tag)
|
||||
VALUES
|
||||
('user-1', 1, 'api', 'prod'),
|
||||
('user-1', 1, 'api', 'critical'),
|
||||
('user-1', 1, 'worker', 'batch'),
|
||||
('user-2', 2, 'api', 'other');
|
||||
`);
|
||||
|
||||
return repository;
|
||||
}
|
||||
|
||||
describe("createSessionTagRepository", () => {
|
||||
it("groups tags by session for a user and host", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
const tags = await repository.listByUserAndHost("user-1", 1);
|
||||
|
||||
expect(tags.get("api")).toEqual(["prod", "critical"]);
|
||||
expect(tags.get("worker")).toEqual(["batch"]);
|
||||
expect(tags.has("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("renames tags by host/session, leaving other hosts alone", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await repository.renameSessionForHost(1, "api", "api-renamed");
|
||||
|
||||
const renamed = await repository.listByUserAndHost("user-1", 1);
|
||||
expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]);
|
||||
expect(renamed.has("api")).toBe(false);
|
||||
|
||||
const other = await repository.listByUserAndHost("user-2", 2);
|
||||
expect(other.get("api")).toEqual(["other"]);
|
||||
});
|
||||
|
||||
it("deletes tags by host/session", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await repository.deleteSessionForHost(1, "api");
|
||||
|
||||
const tags = await repository.listByUserAndHost("user-1", 1);
|
||||
expect(tags.has("api")).toBe(false);
|
||||
expect(tags.get("worker")).toEqual(["batch"]);
|
||||
});
|
||||
|
||||
it("replaces tags for one user/host/session only", async () => {
|
||||
const repository = await createRepository();
|
||||
|
||||
await repository.replaceForUserHostSession("user-1", 1, "api", [
|
||||
"blue",
|
||||
"green",
|
||||
]);
|
||||
|
||||
const tags = await repository.listByUserAndHost("user-1", 1);
|
||||
expect(tags.get("api")).toEqual(["blue", "green"]);
|
||||
expect(tags.get("worker")).toEqual(["batch"]);
|
||||
|
||||
await repository.replaceForUserHostSession("user-1", 1, "worker", []);
|
||||
const cleared = await repository.listByUserAndHost("user-1", 1);
|
||||
expect(cleared.has("worker")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
function fakeSshClient(outputs: Record<string, string>, exitCode = 0) {
|
||||
const client = new EventEmitter() as EventEmitter & {
|
||||
exec: (
|
||||
command: string,
|
||||
cb: (err: Error | null, stream: unknown) => void,
|
||||
) => void;
|
||||
};
|
||||
client.exec = (command, cb) => {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
const matchKey = Object.keys(outputs).find((k) => command.includes(k));
|
||||
const output = matchKey ? outputs[matchKey] : "";
|
||||
cb(null, stream);
|
||||
setImmediate(() => {
|
||||
if (output) stream.emit("data", Buffer.from(output));
|
||||
stream.emit("close", exitCode);
|
||||
});
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("GET /:hostId/overview", () => {
|
||||
it("403s when the host does not exist or is not reachable", async () => {
|
||||
server = await startServer({ hosts: [] });
|
||||
const res = await server.request("GET", "/7/overview");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("403s when the monitor is disabled for the host", async () => {
|
||||
server = await startServer({ enabledHosts: [] });
|
||||
const res = await server.request("GET", "/7/overview");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/not enabled/i);
|
||||
});
|
||||
|
||||
it("403s without the tmux-monitor.use permission", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const res = await server.request("GET", "/7/overview");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("reports tmux unavailable when the exec exits non-zero", async () => {
|
||||
server = await startServer({ sshClient: fakeSshClient({}, 127) });
|
||||
const res = await server.request("GET", "/7/overview");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, sessions: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /:hostId/tags", () => {
|
||||
it("saves tags for the acting user and session", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("PUT", "/7/tags", {
|
||||
body: { sessionName: "work", tags: ["a", "b", "b", " "] },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tags.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("rejects a non-array tags field", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("PUT", "/7/tags", {
|
||||
body: { sessionName: "work", tags: "nope" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /:hostId/sessions", () => {
|
||||
it("rejects an invalid session name", async () => {
|
||||
server = await startServer();
|
||||
const res = await server.request("POST", "/7/sessions", {
|
||||
body: { name: "bad:name" },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createFakeContext } from "@termix/plugin-sdk/testing";
|
||||
import { createTmuxSessionsService } from "../../src/backend/service.js";
|
||||
|
||||
function fakeSshClient(outputs: Record<string, string>, exitCode = 0) {
|
||||
const client = new EventEmitter() as EventEmitter & {
|
||||
exec: (
|
||||
command: string,
|
||||
cb: (err: Error | null, stream: unknown) => void,
|
||||
) => void;
|
||||
};
|
||||
client.exec = (command, cb) => {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
const matchKey = Object.keys(outputs).find((k) => command.includes(k));
|
||||
const output = matchKey ? outputs[matchKey] : "";
|
||||
cb(null, stream);
|
||||
setImmediate(() => {
|
||||
if (output) stream.emit("data", Buffer.from(output));
|
||||
stream.emit("close", exitCode);
|
||||
});
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("createTmuxSessionsService", () => {
|
||||
it("detect() reports unavailable when tmux -V fails", async () => {
|
||||
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
|
||||
const service = createTmuxSessionsService(ctx);
|
||||
const client = fakeSshClient({}, 127);
|
||||
|
||||
await expect(service.detect(client)).resolves.toEqual({
|
||||
available: false,
|
||||
sessions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("detect() lists session names when tmux is available", async () => {
|
||||
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
|
||||
const service = createTmuxSessionsService(ctx);
|
||||
const client = fakeSshClient({
|
||||
"-V": "tmux 3.7b\n",
|
||||
"list-sessions": "main|1|2|1\nlogs|3|4|0\n",
|
||||
});
|
||||
|
||||
await expect(service.detect(client)).resolves.toEqual({
|
||||
available: true,
|
||||
sessions: ["main", "logs"],
|
||||
});
|
||||
});
|
||||
|
||||
it("attachOrCreate() writes the attach command to the stream", async () => {
|
||||
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
|
||||
const service = createTmuxSessionsService(ctx);
|
||||
const writes: string[] = [];
|
||||
const stream = { write: (data: string) => writes.push(data) };
|
||||
|
||||
await service.attachOrCreate(stream, "main");
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).toContain("attach-session -t");
|
||||
expect(writes[0]).toContain("main");
|
||||
});
|
||||
|
||||
it("waitForSession() falls back to the requested name on timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
|
||||
const warn = vi.fn();
|
||||
ctx.log.warn = warn;
|
||||
const service = createTmuxSessionsService(ctx);
|
||||
const client = fakeSshClient({}, 1);
|
||||
|
||||
const result = service.waitForSession(client, "new-session");
|
||||
await vi.advanceTimersByTimeAsync(5100);
|
||||
|
||||
await expect(result).resolves.toBe("new-session");
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectTmux,
|
||||
tmuxCommand,
|
||||
withTmuxPath,
|
||||
} from "../../src/backend/tmux-commands.js";
|
||||
|
||||
describe("tmux command path handling", () => {
|
||||
it("prepends all non-login tmux paths while preserving inherited PATH", () => {
|
||||
expect(withTmuxPath("command -v tmux")).toBe(
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; command -v tmux'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shell-escapes embedded single quotes in wrapped commands", () => {
|
||||
// Asserted as a string so the escaping rule is covered everywhere. The
|
||||
// round-trip below proves it against a real parser, but only where one
|
||||
// exists -- see the note there.
|
||||
expect(withTmuxPath(`printf '%s' "can't"`)).toBe(
|
||||
"/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:\"$PATH\"; export PATH; printf '\\''%s'\\'' \"can'\\''t\"'",
|
||||
);
|
||||
});
|
||||
|
||||
// /bin/sh is not on Windows, and Windows is a supported platform for the
|
||||
// desktop app -- contributors run `npm test` there. CI is ubuntu-only, so it
|
||||
// would never notice this failing.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"produces a command a real shell parses back to the original",
|
||||
() => {
|
||||
const command = withTmuxPath(`printf '%s' "can't"`);
|
||||
|
||||
expect(
|
||||
execFileSync("/bin/sh", ["-c", command], { encoding: "utf8" }),
|
||||
).toBe("can't");
|
||||
},
|
||||
);
|
||||
|
||||
it("runs every tmux invocation in UTF-8 mode through the path wrapper", () => {
|
||||
expect(tmuxCommand("list-sessions")).toBe(
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects tmux with the UTF-8 wrapper", async () => {
|
||||
const commands: string[] = [];
|
||||
const conn = {
|
||||
exec(command: string, callback: (error: null, stream: never) => void) {
|
||||
commands.push(command);
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
callback(null, stream as never);
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (commands.length === 1) {
|
||||
stream.emit("data", Buffer.from("tmux 3.7b\n"));
|
||||
stream.emit("close", 0);
|
||||
return;
|
||||
}
|
||||
stream.emit("close", 1);
|
||||
});
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
await expect(detectTmux(conn)).resolves.toEqual({
|
||||
available: true,
|
||||
sessions: [],
|
||||
});
|
||||
expect(commands).toEqual([
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u -V'`,
|
||||
`/bin/sh -c 'PATH=/opt/homebrew/bin:/usr/local/bin:/opt/bin:/usr/pkg/bin:"$PATH"; export PATH; tmux -u list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null'`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const declared = (views?: Array<{ id: string }>) =>
|
||||
(views ?? []).map((view) => view.id).sort();
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function stubApi(overrides: Partial<PluginApiClient> = {}): PluginApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: {} })),
|
||||
post: vi.fn(async () => ({ data: {} })),
|
||||
put: vi.fn(async () => ({ data: {} })),
|
||||
patch: vi.fn(async () => ({ data: {} })),
|
||||
delete: vi.fn(async () => ({ data: {} })),
|
||||
...overrides,
|
||||
} as PluginApiClient;
|
||||
}
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
const contributes = manifest.contributes as unknown as Record<
|
||||
string,
|
||||
Array<{ id: string }> | undefined
|
||||
>;
|
||||
const tabs = declared(contributes.tabs);
|
||||
expect(rendered.registered.tabs()).toEqual(["tmux_monitor"]);
|
||||
expect(rendered.registered.hostEditorSections()).toEqual(["tmux-monitor"]);
|
||||
expect(rendered.registered.hostActions()).toEqual([
|
||||
expect.objectContaining({ id: "tmux_monitor", tabType: undefined }),
|
||||
]);
|
||||
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
await app.deactivate();
|
||||
expect(app.registered.tabs()).toEqual([]);
|
||||
expect(app.registered.hostActions()).toEqual([]);
|
||||
expect(app.registered.hostEditorSections()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host editor section", () => {
|
||||
it("writes the enable switch into the form's tmux-monitor plugin settings", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi(),
|
||||
});
|
||||
let form: Record<string, unknown> = {
|
||||
name: "web",
|
||||
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: false } },
|
||||
};
|
||||
const updateForm = vi.fn(
|
||||
(
|
||||
patch: (current: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => {
|
||||
form = patch(form);
|
||||
},
|
||||
);
|
||||
|
||||
rendered.renderHostEditorSection("tmux-monitor", {
|
||||
form,
|
||||
setField: vi.fn(),
|
||||
updateForm,
|
||||
protocols: { ssh: true },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
const settings = (form.pluginSettings as Record<string, unknown>)[
|
||||
"tmux-monitor"
|
||||
] as { enableTmuxMonitor: boolean };
|
||||
expect(settings.enableTmuxMonitor).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tmuxMonitorEnabled } from "../../src/frontend/host-tmux-monitor";
|
||||
|
||||
describe("tmuxMonitorEnabled", () => {
|
||||
it("is false when there is no pluginSettings", () => {
|
||||
expect(tmuxMonitorEnabled(undefined)).toBe(false);
|
||||
expect(
|
||||
tmuxMonitorEnabled({ id: "1", name: "a", ip: "1.2.3.4", port: 22 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when another plugin's settings are present but not this one's", () => {
|
||||
expect(
|
||||
tmuxMonitorEnabled({
|
||||
id: "1",
|
||||
name: "a",
|
||||
ip: "1.2.3.4",
|
||||
port: 22,
|
||||
pluginSettings: { tunnels: { enableTunnel: true } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is true only when enableTmuxMonitor is exactly true", () => {
|
||||
expect(
|
||||
tmuxMonitorEnabled({
|
||||
id: "1",
|
||||
name: "a",
|
||||
ip: "1.2.3.4",
|
||||
port: 22,
|
||||
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: true } },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
tmuxMonitorEnabled({
|
||||
id: "1",
|
||||
name: "a",
|
||||
ip: "1.2.3.4",
|
||||
port: 22,
|
||||
pluginSettings: { "tmux-monitor": { enableTmuxMonitor: "true" } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user