feat: convert tmux monitoring into a plugin (B10)

This commit is contained in:
LukeGus committed 2026-09-23 22:42:56 -05:00
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'`,
]);
});
});