mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +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
@@ -1,105 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { TmuxSessionTagRepository } from "../../../database/repositories/tmux-session-tag-repository.js";
|
||||
|
||||
describe("TmuxSessionTagRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<TmuxSessionTagRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO tmux_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 new TmuxSessionTagRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("groups tags by session for a user and host", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const tags = await repo.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 and deletes session tags by host/session", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(await repo.renameSessionForHost(1, "api", "api-renamed")).toBe(2);
|
||||
expect(await repo.renameSessionForHost(1, "missing", "noop")).toBe(0);
|
||||
|
||||
const renamed = await repo.listByUserAndHost("user-1", 1);
|
||||
expect(renamed.get("api-renamed")).toEqual(["prod", "critical"]);
|
||||
expect(renamed.has("api")).toBe(false);
|
||||
|
||||
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(2);
|
||||
expect(await repo.deleteSessionForHost(1, "api-renamed")).toBe(0);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("replaces tags for one user/host/session", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.replaceForUserHostSession("user-1", 1, "api", [
|
||||
"blue",
|
||||
"green",
|
||||
]),
|
||||
).toBe(4);
|
||||
|
||||
const tags = await repo.listByUserAndHost("user-1", 1);
|
||||
expect(tags.get("api")).toEqual(["blue", "green"]);
|
||||
expect(tags.get("worker")).toEqual(["batch"]);
|
||||
|
||||
expect(
|
||||
await repo.replaceForUserHostSession("user-1", 1, "worker", []),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await repo.replaceForUserHostSession("user-1", 1, "missing", []),
|
||||
).toBe(0);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all tags for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(3);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserAndHost("user-1", 1)).toEqual(new Map());
|
||||
expect(await repo.listByUserAndHost("user-2", 2)).toEqual(
|
||||
new Map([["api", ["other"]]]),
|
||||
);
|
||||
expect(writeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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 "../../../hosts/tmux/helper.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'`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SEP,
|
||||
parseSessions,
|
||||
parseWindows,
|
||||
parsePanes,
|
||||
parsePsOutput,
|
||||
parseGpuOutput,
|
||||
buildPaneMetrics,
|
||||
attachPanesToWindows,
|
||||
shellEscape,
|
||||
type ProcessInfo,
|
||||
type TmuxWindow,
|
||||
} from "../../../hosts/tmux/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,149 @@
|
||||
/**
|
||||
* The tmux monitor host-column migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host with the monitor on must keep it on
|
||||
* once the ssh_data column is dropped. Running it twice must not duplicate
|
||||
* or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_tmux_monitor: boolean;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runTmuxMonitorSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runTmuxMonitorSettingsMigration", () => {
|
||||
it("moves a host's enabled switch into plugin settings", async () => {
|
||||
hostRows.push({ id: 1, enable_tmux_monitor: true });
|
||||
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
|
||||
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a disabled host untouched", async () => {
|
||||
hostRows.push({ id: 3, enable_tmux_monitor: false });
|
||||
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_tmux_monitor: true });
|
||||
|
||||
await runTmuxMonitorSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({ id: 5, enable_tmux_monitor: true });
|
||||
await pluginSettingsRepository.set(
|
||||
"tmux-monitor",
|
||||
"host",
|
||||
"5",
|
||||
"enableTmuxMonitor",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runTmuxMonitorSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user