feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

@@ -1,119 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const statsApiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }));
const remoteStatsApiMock = vi.hoisted(() => ({ get: vi.fn() }));
const sshHostApiMock = vi.hoisted(() => ({ get: vi.fn() }));
const resolveConnectionOriginMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
statsApi: statsApiMock,
sshHostApi: sshHostApiMock,
getRemoteStatsApi: () => remoteStatsApiMock,
isElectron: () => true,
handleApiError: vi.fn(),
}));
vi.mock("@/lib/hosts-request-cache", () => ({
getCachedServerStatuses: (loader: () => Promise<unknown>) => loader(),
}));
vi.mock("@/lib/connection-origin", () => ({
resolveConnectionOrigin: resolveConnectionOriginMock,
}));
import {
getAllServerStatuses,
getServerMetricsById,
sendMetricsHeartbeat,
} from "../../api/host-metrics-status-api";
beforeEach(() => {
vi.clearAllMocks();
window.electronAPI = {
invoke: vi.fn(async (channel: string) =>
channel === "get-remote-sync-config"
? { serverUrl: "https://termix.example.test" }
: null,
),
} as unknown as NonNullable<typeof window.electronAPI>;
statsApiMock.get.mockResolvedValue({ data: { 1: { status: "online" } } });
remoteStatsApiMock.get.mockResolvedValue({
data: { 2: { status: "reachable" } },
});
});
describe("status check origin routing", () => {
it("only asks the embedded backend to poll local-origin hosts", async () => {
sshHostApiMock.get.mockResolvedValue({
data: [
{ id: 1, connectionOrigin: "local" },
{ id: 2, connectionOrigin: "remote" },
],
});
resolveConnectionOriginMock.mockImplementation(async (host) =>
host.connectionOrigin === "local" ? "local" : "remote",
);
await expect(getAllServerStatuses()).resolves.toEqual({
1: { status: "online" },
2: { status: "reachable" },
});
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
timeout: 2000,
params: { hostIds: "1" },
__silentRetry: true,
});
});
it("sends an empty allowlist when every host uses the remote server", async () => {
sshHostApiMock.get.mockResolvedValue({
data: [{ id: 2, connectionOrigin: "remote" }],
});
resolveConnectionOriginMock.mockResolvedValue("remote");
await getAllServerStatuses();
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
timeout: 2000,
params: { hostIds: "" },
__silentRetry: true,
});
});
});
describe("metrics request coalescing", () => {
it("shares simultaneous reads for the same host", async () => {
statsApiMock.get.mockResolvedValueOnce({
status: 200,
data: { cpu: { percent: 12 } },
});
const [first, second] = await Promise.all([
getServerMetricsById(991),
getServerMetricsById(991),
]);
expect(first).toEqual({ cpu: { percent: 12 } });
expect(second).toBe(first);
expect(statsApiMock.get).toHaveBeenCalledOnce();
});
});
describe("metrics viewer heartbeat", () => {
it("reports a swept viewer session without throwing or logging", async () => {
statsApiMock.post.mockResolvedValueOnce({ status: 404 });
await expect(sendMetricsHeartbeat("expired-viewer")).resolves.toBe(false);
expect(statsApiMock.post).toHaveBeenCalledWith(
"/metrics/heartbeat",
{ viewerSessionId: "expired-viewer" },
{ validateStatus: expect.any(Function) },
);
});
it("keeps a live viewer session", async () => {
statsApiMock.post.mockResolvedValueOnce({ status: 200 });
await expect(sendMetricsHeartbeat("live-viewer")).resolves.toBe(true);
});
});
-36
View File
@@ -1,36 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { buildRdpFile, validateNativeRdpOptions } =
require("../../../../electron/native-rdp.cjs") as {
buildRdpFile: (options: Record<string, unknown>) => string;
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
};
describe("native Windows RDP", () => {
it("creates a credential-prompting RDP file without a password", () => {
const result = buildRdpFile({
host: "server.example.com",
port: 3390,
username: "alice",
domain: "EXAMPLE",
password: "must-not-be-written",
});
expect(result).toContain("full address:s:server.example.com:3390");
expect(result).toContain("username:s:EXAMPLE\\alice");
expect(result).toContain("prompt for credentials:i:1");
expect(result).not.toContain("must-not-be-written");
});
it("rejects unsafe host and port values", () => {
expect(() =>
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
).not.toThrow();
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
expect(() =>
validateNativeRdpOptions({ host: "server", port: 70000 }),
).toThrow();
});
});
@@ -1,98 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type { AiProvider } from "@/api/ai-api";
const api = vi.hoisted(() => ({
createAiProvider: vi.fn(),
deleteAiProvider: vi.fn(),
getAiProviderModels: vi.fn(),
probeAiModels: vi.fn(),
updateAiProvider: vi.fn(),
}));
vi.mock("@/api/ai-api", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { AiProviderSettings } from "@/features/ai/AiProviderSettings";
const provider: AiProvider = {
id: 7,
providerType: "ollama",
label: "Local Ollama",
baseUrl: "http://localhost:11434",
apiKeyPrefix: null,
defaultModel: "llama3.1",
enabled: true,
createdAt: "2026-08-21T00:00:00Z",
};
beforeEach(() => {
api.getAiProviderModels.mockReset();
api.getAiProviderModels.mockResolvedValue([]);
api.updateAiProvider.mockReset();
api.updateAiProvider.mockResolvedValue(provider);
});
afterEach(cleanup);
describe("AiProviderSettings", () => {
it("edits an existing provider name and configured model", async () => {
const onChanged = vi.fn();
render(<AiProviderSettings providers={[provider]} onChanged={onChanged} />);
expect(screen.getByText(/llama3\.1/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
const label = screen.getByLabelText("ai.providerLabel");
const model = screen.getByLabelText("ai.defaultModel");
fireEvent.change(label, { target: { value: "Production Ollama" } });
fireEvent.change(model, { target: { value: "qwen3:32b" } });
fireEvent.click(screen.getByRole("button", { name: "ai.save" }));
await waitFor(() => {
expect(api.updateAiProvider).toHaveBeenCalledWith(7, {
label: "Production Ollama",
defaultModel: "qwen3:32b",
});
});
expect(onChanged).toHaveBeenCalledWith(7);
});
it("loads the saved provider model list when editing starts", async () => {
api.getAiProviderModels.mockResolvedValue(["llama3.1", "qwen3:32b"]);
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
await waitFor(() => {
expect(api.getAiProviderModels).toHaveBeenCalledWith(7);
});
expect(
screen.getByRole("combobox", { name: "ai.defaultModel" }),
).toBeTruthy();
});
it("shows the provider error when refreshing models fails", async () => {
api.getAiProviderModels.mockRejectedValue(
new Error("Add llm.internal to the AI endpoint allowlist"),
);
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
expect(
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
).toBeTruthy();
});
});
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest";
import {
fieldLabel,
humanize,
mentionLabel,
toolLabel,
} from "@/features/ai/labels";
import { AI_TOOLS } from "../../../../../plugins/ai/backend/tools/catalog";
describe("humanize", () => {
it("splits camelCase into words", () => {
// The case that shipped as "thresholddurationseconds".
expect(humanize("thresholdDurationSeconds")).toBe(
"Threshold duration seconds",
);
});
it("splits snake_case into words", () => {
expect(humanize("propose_create_automation")).toBe(
"Propose create automation",
);
});
it("leaves a single word alone apart from casing", () => {
expect(humanize("port")).toBe("Port");
});
it("returns the input when there is nothing to split", () => {
expect(humanize("")).toBe("");
});
});
describe("fieldLabel", () => {
it("uses the mapped name where there is one", () => {
expect(fieldLabel("thresholdDurationSeconds")).toBe("Duration (seconds)");
expect(fieldLabel("cooldownMinutes")).toBe("Cooldown (minutes)");
expect(fieldLabel("ip")).toBe("Address");
});
it("falls back to humanizing an unmapped key", () => {
expect(fieldLabel("someFutureField")).toBe("Some future field");
});
it("never returns a run-on identifier", () => {
for (const key of ["thresholdDurationSeconds", "hostIds", "triggerType"]) {
expect(fieldLabel(key)).not.toBe(key.toLowerCase());
}
});
});
describe("toolLabel", () => {
it("phrases tools as actions rather than function names", () => {
expect(toolLabel("list_hosts")).toBe("Reading hosts");
expect(toolLabel("propose_create_fleet")).toBe("Create fleet");
});
it("covers every tool in the catalog", () => {
// A new tool without a label would surface its raw snake_case name.
for (const tool of AI_TOOLS) {
const label = toolLabel(tool.name);
expect(label, tool.name).not.toContain("_");
expect(label, tool.name).not.toBe(tool.name);
}
});
});
describe("mentionLabel", () => {
it("capitalises the mention kinds", () => {
expect(mentionLabel("host")).toBe("Host");
expect(mentionLabel("snippet")).toBe("Snippet");
expect(mentionLabel("automation")).toBe("Automation");
});
});
@@ -39,7 +39,7 @@ vi.mock("@xterm/addon-fit", () => ({
}));
vi.mock(
"../../../../../plugins/remote-desktop/frontend/GuacamoleDisplay.tsx",
"../../../../../plugins/remote-desktop/src/frontend/GuacamoleDisplay.tsx",
() => ({
GuacamoleDisplay: () => <div data-testid="guacamole-display" />,
}),
@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { defaultTrigger } from "../../../../../plugins/automations/frontend/automations/TriggerCard";
import { emptyDraft } from "../../../../../plugins/automations/frontend/automations/AutomationEditor";
import { newStepId } from "../../../../../plugins/automations/frontend/automations/editor-types";
import { AUTOMATION_DEFINITION_VERSION } from "@/types/automations";
describe("emptyDraft", () => {
it("starts on a metric threshold with no steps", () => {
const draft = emptyDraft();
expect(draft.name).toBe("");
expect(draft.enabled).toBe(true);
expect(draft.definition.version).toBe(AUTOMATION_DEFINITION_VERSION);
expect(draft.definition.trigger.kind).toBe("metric_threshold");
expect(draft.definition.steps).toEqual([]);
});
});
describe("defaultTrigger", () => {
it("gives every trigger kind a usable starting shape", () => {
expect(defaultTrigger("metric_threshold")).toMatchObject({
kind: "metric_threshold",
operator: ">",
cooldownMinutes: 15,
});
expect(defaultTrigger("host_status")).toMatchObject({
kind: "host_status",
to: "offline",
});
expect(defaultTrigger("health_check")).toMatchObject({
kind: "health_check",
to: "failing",
});
expect(defaultTrigger("schedule")).toMatchObject({ kind: "schedule" });
expect(defaultTrigger("docker_event")).toMatchObject({
kind: "docker_event",
event: "exited",
});
expect(defaultTrigger("webhook")).toMatchObject({ kind: "webhook" });
});
it("defaults a schedule to a valid cron expression", () => {
const trigger = defaultTrigger("schedule");
expect(trigger).toHaveProperty("cron");
// Five fields, which is what the backend parser requires.
expect(
String((trigger as { cron: string }).cron).split(/\s+/),
).toHaveLength(5);
});
});
describe("newStepId", () => {
it("produces distinct ids", () => {
const ids = new Set(Array.from({ length: 100 }, () => newStepId()));
expect(ids.size).toBe(100);
});
});
@@ -1,111 +0,0 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import type { ServerMetrics } from "@/main-axios";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const SAMPLE = {
cpu: { percent: 12, cores: 8, load: [0.5, 0.4, 0.3] },
memory: { percent: 40, usedGiB: 12.8, totalGiB: 32 },
disk: { percent: 55 },
gpu: {
source: "nvidia-smi",
gpus: [
{
index: 0,
uuid: "GPU-0",
name: "NVIDIA GeForce RTX 2070 SUPER",
driverVersion: "580.173.02",
utilizationPercent: 0,
memoryUsedMiB: 5689,
memoryTotalMiB: 8192,
memoryPercent: 69.4,
temperatureCelsius: 35,
powerDrawWatts: 24,
powerLimitWatts: 215,
fanPercent: 0,
},
],
processes: [],
},
lastChecked: "2026-09-18T07:00:00.000Z",
} as ServerMetrics;
vi.mock("@/main-axios.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("@/main-axios.ts")>()),
getServerStatusById: vi.fn(async () => ({ status: "online" })),
getServerMetricsById: vi.fn(async () => SAMPLE),
startMetricsPolling: vi.fn(async () => ({ viewerSessionId: "viewer-1" })),
stopMetricsPolling: vi.fn(async () => undefined),
sendMetricsHeartbeat: vi.fn(async () => true),
getSnippets: vi.fn(async () => []),
getSSHHosts: vi.fn(async () => []),
logActivity: vi.fn(async () => undefined),
}));
vi.mock("@/contexts/UiPreferencesContext", async (importOriginal) => ({
...(await importOriginal<typeof import("@/contexts/UiPreferencesContext")>()),
useAreaPreferences: () => ({ columns: 3 }),
}));
vi.mock("@/hooks/use-confirmation.ts", () => ({
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
}));
vi.mock(
"../../../../../plugins/host-metrics/frontend/hooks/useHostMetricsPreferences.ts",
() => ({
useHostMetricsPreferences: () => ({
layout: {
slots: [{ id: "gpu", order: 0, colSpan: 2, height: null }],
columns: 3,
},
setLayout: vi.fn(),
loaded: true,
}),
}),
);
import { HostMetricsTab } from "../../../../../plugins/host-metrics/frontend/HostMetricsTab";
beforeAll(() => {
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
describe("HostMetricsTab", () => {
it("draws the trend lines from the first metrics sample, before the next poll", async () => {
render(
<HostMetricsTab
hostConfig={{
id: 1,
name: "gpu-box",
ip: "10.0.0.5",
username: "admin",
port: 22,
authType: "password",
statsConfig: JSON.stringify({
metricsEnabled: true,
metricsInterval: 30,
enabledWidgets: ["gpu"],
}),
}}
/>,
);
const trend = await screen.findByRole(
"figure",
{ name: "hostMetrics.gpu.memory" },
{ timeout: 5000 },
);
expect(trend.querySelector("svg")).not.toBeNull();
});
});
@@ -1,119 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import type { ServerMetrics } from "@/main-axios";
import type { GpuMetrics } from "@/types/stats-widgets";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { GpuCard } from "../../../../../../plugins/host-metrics/frontend/cards/GpuCard";
const GPU_METRICS: GpuMetrics = {
source: "nvidia-smi",
gpus: [
{
index: 0,
uuid: "GPU-rtx",
name: "NVIDIA GeForce RTX 4090",
driverVersion: "550.54.14",
utilizationPercent: 37,
memoryUsedMiB: 8123,
memoryTotalMiB: 24564,
memoryPercent: 33.1,
temperatureCelsius: 61,
powerDrawWatts: 182.4,
powerLimitWatts: 450,
fanPercent: 45,
},
{
index: 1,
uuid: "GPU-h100",
name: "NVIDIA H100 80GB HBM3",
driverVersion: "550.54.14",
utilizationPercent: 0,
memoryUsedMiB: 1,
memoryTotalMiB: 81559,
memoryPercent: 0,
temperatureCelsius: 34,
powerDrawWatts: null,
powerLimitWatts: 700,
fanPercent: null,
},
],
processes: [
{
gpuIndex: 0,
pid: 12345,
name: "/usr/bin/python3",
memoryUsedMiB: 7890,
},
{ gpuIndex: 1, pid: 23456, name: "worker", memoryUsedMiB: null },
],
};
function renderCard(gpu: GpuMetrics | undefined, gpuHistories = {}) {
const metrics = { gpu } as ServerMetrics;
render(<GpuCard metrics={metrics} gpuHistories={gpuHistories} />);
}
afterEach(cleanup);
describe("GpuCard", () => {
it("explains that no NVIDIA GPU was found", () => {
renderCard({ source: "none", gpus: [], processes: [] });
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
});
it("treats metrics from a backend without gpu support as no GPU", () => {
renderCard(undefined);
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
});
it("shows the readings of every GPU", () => {
renderCard(GPU_METRICS);
expect(screen.getByText("NVIDIA GeForce RTX 4090")).toBeInTheDocument();
expect(screen.getByText("NVIDIA H100 80GB HBM3")).toBeInTheDocument();
expect(screen.getByText("7.9/24.0G")).toBeInTheDocument();
expect(screen.getByText("0.0/79.6G")).toBeInTheDocument();
expect(screen.getByText("61°C")).toBeInTheDocument();
expect(screen.getByText("182/450W")).toBeInTheDocument();
expect(screen.getByText("N/A/700W")).toBeInTheDocument();
expect(screen.getByText("45%")).toBeInTheDocument();
expect(screen.queryByText("hostMetrics.gpu.noDevice")).toBeNull();
});
it("draws each GPU's history as labelled utilization and VRAM trends", () => {
renderCard(
{ ...GPU_METRICS, gpus: [GPU_METRICS.gpus[0]] },
{ 0: { utilization: [10, 20], memory: [30, 31] } },
);
for (const name of [
"hostMetrics.gpu.utilization",
"hostMetrics.gpu.memory",
]) {
const trend = screen.getByRole("figure", { name });
expect(trend.querySelector("svg")).not.toBeNull();
}
});
it("lists GPU processes by executable name with the GPU and VRAM they hold", () => {
renderCard(GPU_METRICS);
const python = screen.getByText("python3");
expect(python).toHaveAttribute("title", "/usr/bin/python3");
expect(screen.getByText("7.7G · GPU 0")).toBeInTheDocument();
expect(screen.getByText("N/A · GPU 1")).toBeInTheDocument();
});
it("says so when no process is using a GPU", () => {
renderCard({ ...GPU_METRICS, processes: [] });
expect(screen.getByText("hostMetrics.gpu.noProcesses")).toBeInTheDocument();
});
});
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import type { GpuDevice } from "@/types/stats-widgets";
import { appendGpuHistories } from "../../../../../../plugins/host-metrics/frontend/cards/gpu-history";
function gpu(
index: number,
utilizationPercent: number | null,
memoryPercent: number | null,
): GpuDevice {
return {
index,
uuid: `GPU-${index}`,
name: `GPU ${index}`,
driverVersion: "550.54.14",
utilizationPercent,
memoryUsedMiB: null,
memoryTotalMiB: null,
memoryPercent,
temperatureCelsius: null,
powerDrawWatts: null,
powerLimitWatts: null,
fanPercent: null,
};
}
describe("appendGpuHistories", () => {
it("records utilization and memory in a separate series per GPU", () => {
const first = appendGpuHistories({}, [gpu(0, 10, 20), gpu(1, 90, 5)], 30);
const second = appendGpuHistories(
first,
[gpu(0, 15, 25), gpu(1, 80, 6)],
30,
);
expect(second).toEqual({
0: { utilization: [10, 15], memory: [20, 25] },
1: { utilization: [90, 80], memory: [5, 6] },
});
});
it("keeps only the newest samples once the series is full", () => {
let histories = {};
for (const value of [1, 2, 3, 4]) {
histories = appendGpuHistories(histories, [gpu(0, value, value)], 3);
}
expect(histories).toEqual({
0: { utilization: [2, 3, 4], memory: [2, 3, 4] },
});
});
it("records an unavailable reading as zero", () => {
expect(appendGpuHistories({}, [gpu(0, null, null)], 30)).toEqual({
0: { utilization: [0], memory: [0] },
});
});
it("drops the series of a GPU that is no longer reported", () => {
const before = appendGpuHistories({}, [gpu(0, 10, 10), gpu(1, 20, 20)], 30);
expect(appendGpuHistories(before, [gpu(0, 11, 11)], 30)).toEqual({
0: { utilization: [10, 11], memory: [10, 11] },
});
expect(appendGpuHistories(before, undefined, 30)).toEqual({});
});
});
@@ -1,69 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ServerMetrics } from "@/main-axios";
import type { GpuMetrics } from "@/types/stats-widgets";
import { metricsChangeKey } from "../../../../../plugins/host-metrics/frontend/metrics-change-key";
function poll(gpu: GpuMetrics): ServerMetrics {
return {
cpu: { percent: 3, cores: 8, load: [0.1, 0.1, 0.1] },
memory: { percent: 40, usedGiB: 12, totalGiB: 32 },
disk: { percent: 55 },
gpu,
lastChecked: "2026-09-18T00:00:00.000Z",
} as ServerMetrics;
}
function gpuAt(utilizationPercent: number, memoryPercent: number): GpuMetrics {
return {
source: "nvidia-smi",
gpus: [
{
index: 0,
uuid: "GPU-0",
name: "NVIDIA GeForce RTX 4090",
driverVersion: "550.54.14",
utilizationPercent,
memoryUsedMiB: 1000,
memoryTotalMiB: 24564,
memoryPercent,
temperatureCelsius: 50,
powerDrawWatts: 30,
powerLimitWatts: 450,
fanPercent: 30,
},
],
processes: [],
};
}
describe("metricsChangeKey", () => {
it("changes when only GPU utilization moves", () => {
expect(metricsChangeKey(poll(gpuAt(0, 4)))).not.toBe(
metricsChangeKey(poll(gpuAt(95, 4))),
);
});
it("changes when only GPU memory moves", () => {
expect(metricsChangeKey(poll(gpuAt(0, 4)))).not.toBe(
metricsChangeKey(poll(gpuAt(0, 80))),
);
});
it("changes when a GPU process starts", () => {
const idle = gpuAt(0, 4);
const busy: GpuMetrics = {
...idle,
processes: [
{ gpuIndex: 0, pid: 4242, name: "python3", memoryUsedMiB: 900 },
],
};
expect(metricsChangeKey(poll(idle))).not.toBe(metricsChangeKey(poll(busy)));
});
it("ignores GPU jitter inside the same 5% bucket", () => {
expect(metricsChangeKey(poll(gpuAt(41, 20)))).toBe(
metricsChangeKey(poll(gpuAt(42, 21))),
);
});
});
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import {
selectTemperatureSensor,
temperaturePreferenceKey,
} from "../../../../../plugins/host-metrics/frontend/temperature-preference";
const sensors = [
{ label: "k10temp: Tctl", celsius: 55 },
{ label: "nvme-pci-0100: Composite", celsius: 70 },
];
describe("temperature sensor preference", () => {
it("selects the configured sensor by its complete label", () => {
expect(selectTemperatureSensor(sensors, "k10temp: Tctl")).toEqual(
sensors[0],
);
});
it("falls back when a sensor disappears", () => {
expect(selectTemperatureSensor(sensors, "missing")).toBeNull();
});
it("scopes the preference to a host", () => {
expect(temperaturePreferenceKey(42)).toBe(
"termix-host-metrics:42:temperature-sensor",
);
});
});
@@ -1,59 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../../../../plugins/proxmox/frontend/proxmox-import-auth";
describe("resolveProxmoxImportAuth", () => {
it("inherits an available credential even with the default 'password' authType", () => {
// Regression guard: a Proxmox host authenticating via a Termix credential
// used to yield authType 'none' for imported guests because the default
// authType is 'password', not 'credential'.
expect(resolveProxmoxImportAuth("password", 42)).toEqual({
authType: "credential",
credentialId: 42,
overrideCredentialUsername: false,
});
});
it("uses the credential when authType is explicitly 'credential'", () => {
expect(resolveProxmoxImportAuth("credential", 7)).toEqual({
authType: "credential",
credentialId: 7,
overrideCredentialUsername: false,
});
});
it("uses the credential when no authType is given", () => {
expect(resolveProxmoxImportAuth(undefined, 5)).toEqual({
authType: "credential",
credentialId: 5,
overrideCredentialUsername: false,
});
});
it("lets an explicit secretless authType win over an available credential", () => {
expect(resolveProxmoxImportAuth("none", 42)).toEqual({ authType: "none" });
expect(resolveProxmoxImportAuth("tailscale", 42)).toEqual({
authType: "tailscale",
});
expect(resolveProxmoxImportAuth("agent", 42)).toEqual({
authType: "agent",
});
});
it("falls back to 'none' when no credential is available", () => {
expect(resolveProxmoxImportAuth("password", null)).toEqual({
authType: "none",
});
expect(resolveProxmoxImportAuth("key", undefined)).toEqual({
authType: "none",
});
expect(resolveProxmoxImportAuth(undefined, undefined)).toEqual({
authType: "none",
});
});
it("passes through a non secret-backed special type without a credential", () => {
expect(resolveProxmoxImportAuth("opkssh", null)).toEqual({
authType: "opkssh",
});
});
});
@@ -1,62 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AdminTailscaleSection } from "../../../../../plugins/tailscale/frontend/AdminTailscaleSection";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
describe("AdminTailscaleSection", () => {
it("renders the API key and base URL fields and saves on click", async () => {
const user = userEvent.setup();
const handleSave = vi.fn();
render(
<AdminTailscaleSection
open
onToggle={vi.fn()}
tailscaleApiKey="tskey-api-abc"
setTailscaleApiKey={vi.fn()}
tailscaleApiBaseUrl="https://api.tailscale.com/api/v2"
setTailscaleApiBaseUrl={vi.fn()}
handleSaveTailscaleApiKey={handleSave}
/>,
);
expect(
(
screen.getByPlaceholderText(
"tskey-api-... / hskey-api-...",
) as HTMLInputElement
).value,
).toBe("tskey-api-abc");
expect(
(
screen.getByPlaceholderText(
"https://api.tailscale.com/api/v2",
) as HTMLInputElement
).value,
).toBe("https://api.tailscale.com/api/v2");
await user.click(screen.getByText("common.save"));
expect(handleSave).toHaveBeenCalledOnce();
});
it("does not render its content when closed", () => {
render(
<AdminTailscaleSection
open={false}
onToggle={vi.fn()}
tailscaleApiKey=""
setTailscaleApiKey={vi.fn()}
tailscaleApiBaseUrl=""
setTailscaleApiBaseUrl={vi.fn()}
handleSaveTailscaleApiKey={vi.fn()}
/>,
);
expect(
screen.queryByPlaceholderText("tskey-api-... / hskey-api-..."),
).toBeNull();
});
});
@@ -1,76 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const getTailscaleDevicesMock = vi.hoisted(() => vi.fn());
vi.mock("../../../../../plugins/tailscale/frontend/tailscale-api", () => ({
getTailscaleDevices: getTailscaleDevicesMock,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { TailscaleDevicesPanel } from "../../../../../plugins/tailscale/frontend/TailscaleDevicesPanel";
beforeEach(() => {
vi.clearAllMocks();
});
describe("TailscaleDevicesPanel", () => {
it("shows the no-api-key message when hasApiKey is false", async () => {
getTailscaleDevicesMock.mockResolvedValue({
devices: [],
hasApiKey: false,
});
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() =>
expect(screen.getByText("hosts.tailscaleNoApiKey")).toBeTruthy(),
);
});
it("shows the empty-devices message when the tailnet has no devices", async () => {
getTailscaleDevicesMock.mockResolvedValue({ devices: [], hasApiKey: true });
render(<TailscaleDevicesPanel onConnect={vi.fn()} />);
await waitFor(() =>
expect(screen.getByText("hosts.tailscaleNoDevices")).toBeTruthy(),
);
});
it("renders devices and connects with an ephemeral tailscale host", async () => {
const user = userEvent.setup();
getTailscaleDevicesMock.mockResolvedValue({
hasApiKey: true,
devices: [
{
id: "d1",
name: "box",
hostname: "box",
addresses: ["100.64.0.1"],
os: "linux",
lastSeen: "2026-01-01T00:00:00Z",
},
],
});
const onConnect = vi.fn();
render(<TailscaleDevicesPanel onConnect={onConnect} />);
await waitFor(() => expect(screen.getByText("box")).toBeTruthy());
await user.click(
screen.getByText("newUi.sidebar.quickConnect.connectToTerminal"),
);
expect(onConnect).toHaveBeenCalledOnce();
const [host, type] = onConnect.mock.calls[0];
expect(type).toBe("terminal");
expect(host.ip).toBe("100.64.0.1");
expect(host.authType).toBe("tailscale");
expect(host.username).toBe("root");
});
});
@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const authApiMock = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }));
const handleApiErrorMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
statsApi: { get: vi.fn(), patch: vi.fn() },
handleApiError: handleApiErrorMock,
}));
import { getTailscaleDevices } from "../../../../../plugins/tailscale/frontend/tailscale-api";
beforeEach(() => {
vi.clearAllMocks();
});
describe("getTailscaleDevices", () => {
it("preserves configured-key state when discovery fails", async () => {
authApiMock.get.mockRejectedValue({
isAxiosError: true,
response: {
data: {
devices: [],
hasApiKey: true,
error: "Failed to fetch Tailscale devices",
},
},
});
await expect(getTailscaleDevices()).resolves.toEqual({
devices: [],
hasApiKey: true,
error: "Failed to fetch Tailscale devices",
});
expect(handleApiErrorMock).not.toHaveBeenCalled();
});
it("uses normal API error handling when no structured state is available", async () => {
const error = { isAxiosError: true, response: { data: {} } };
authApiMock.get.mockRejectedValue(error);
await expect(getTailscaleDevices()).rejects.toBe(error);
expect(handleApiErrorMock).toHaveBeenCalledWith(
error,
"fetch Tailscale devices",
);
});
});
@@ -1,328 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspacePayload,
buildWorkspaceTabSnapshots,
remapSlotIds,
resolveWorkspaceTabTarget,
} from "../../../../../plugins/workspaces/frontend/workspaceUtils";
import type { Host, Tab, WorkspaceTabSnapshot } from "@/types/ui-types";
afterEach(() => vi.unstubAllGlobals());
function makeHost(overrides: Partial<Host> = {}): Host {
return {
id: "1",
name: "web-01",
username: "root",
ip: "10.0.0.1",
port: 22,
folder: "",
online: true,
cpu: null,
ram: null,
lastAccess: "2026-01-01T00:00:00.000Z",
authType: "password",
enableTerminal: true,
enableCommandHistory: true,
syncId: "sync-web-01",
...overrides,
} as Host;
}
function makeTab(overrides: Partial<Tab> = {}): Tab {
return {
id: "tab-1",
instanceId: "instance-1",
type: "terminal",
label: "web-01",
openedAt: 0,
...overrides,
} as Tab;
}
describe("buildWorkspaceTabSnapshots", () => {
it("falls back when randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
const tabs = [makeTab({ id: "t1" }), makeTab({ id: "t2" })];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(2);
expect(snapshots[0].slotId).toBeTruthy();
expect(snapshots[1].slotId).not.toBe(snapshots[0].slotId);
});
it("captures host-bound and singleton tabs with generated slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host, label: "web-01" }),
makeTab({ id: "t2", type: "tunnel", label: "Tunnels" }),
];
const { snapshots, slotIdByTabId } = buildWorkspaceTabSnapshots(
tabs,
genSlotId,
);
expect(snapshots).toHaveLength(2);
expect(snapshots[0]).toMatchObject({
slotId: "slot-0",
type: "terminal",
hostSyncId: "sync-web-01",
hostNameSnapshot: "web-01",
});
expect(snapshots[1]).toMatchObject({
slotId: "slot-1",
type: "tunnel",
hostSyncId: null,
});
expect(slotIdByTabId.get("t1")).toBe("slot-0");
expect(slotIdByTabId.get("t2")).toBe("slot-1");
});
it("excludes rail-view pseudo tabs (host-manager, user-profile, admin-settings)", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "host-manager" }),
makeTab({ id: "t2", type: "user-profile" }),
makeTab({ id: "t3", type: "admin-settings" }),
makeTab({ id: "t4", type: "tunnel" }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(1);
expect(snapshots[0].type).toBe("tunnel");
});
it("excludes the dashboard tab - it's a permanent fallback, not a meaningful part of a saved arrangement", () => {
const tabs: Tab[] = [
makeTab({ id: "t1", type: "dashboard" }),
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots).toHaveLength(1);
expect(snapshots[0].type).toBe("terminal");
});
it("captures serialConfig only for serial tabs", () => {
const tabs: Tab[] = [
makeTab({
id: "t1",
type: "serial",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
},
}),
makeTab({ id: "t2", type: "terminal", host: makeHost() }),
];
const { snapshots } = buildWorkspaceTabSnapshots(tabs);
expect(snapshots[0].serialConfig).toEqual({
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
});
expect(snapshots[0].hostSyncId).toBeNull();
expect(snapshots[1].serialConfig).toBeUndefined();
});
});
describe("remapSlotIds", () => {
it("maps slot ids to live tab ids, preserving nulls and unresolved slots", () => {
const map = new Map([
["slot-a", "new-tab-1"],
["slot-b", "new-tab-2"],
]);
const result = remapSlotIds(
["slot-a", null, "slot-b", "slot-missing"],
map,
);
expect(result).toEqual(["new-tab-1", null, "new-tab-2", null]);
});
});
describe("resolveWorkspaceTabTarget", () => {
const hosts: Host[] = [makeHost({ id: "1", syncId: "sync-web-01" })];
it("resolves a serial snapshot with a config as 'serial'", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "serial",
label: "Serial",
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 9600,
dataBits: 8,
stopBits: 1,
parity: "none",
},
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "serial",
});
});
it("skips a serial snapshot missing its config", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "serial",
label: "Serial",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
it("resolves a host-bound snapshot by matching syncId", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "terminal",
label: "web-01",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("host");
expect((result as { host: Host }).host.syncId).toBe("sync-web-01");
});
it("skips a host-bound snapshot whose host no longer exists", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "terminal",
label: "deleted-host",
hostSyncId: "sync-gone",
hostNameSnapshot: "deleted-host",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
it("resolves a hostless singleton type without a hostSyncId", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "tunnel",
label: "Tunnels",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "singleton",
host: undefined,
});
});
it("resolves a singleton type WITH a resolved host as singleton+host (e.g. fleet-inventory)", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "fleet-inventory",
label: "Fleet",
hostSyncId: "sync-web-01",
};
const result = resolveWorkspaceTabTarget(snapshot, hosts);
expect(result.kind).toBe("singleton");
expect((result as { host?: Host }).host?.syncId).toBe("sync-web-01");
});
it("skips a non-singleton, non-serial type with no resolvable host", () => {
const snapshot: WorkspaceTabSnapshot = {
slotId: "s1",
type: "files",
label: "files",
};
expect(resolveWorkspaceTabTarget(snapshot, hosts)).toEqual({
kind: "skip",
});
});
});
describe("buildWorkspacePayload", () => {
it("builds a full payload including pane assignment remapped to slot ids", () => {
let counter = 0;
const genSlotId = () => `slot-${counter++}`;
const host = makeHost();
const tabs: Tab[] = [
makeTab({ id: "t1", type: "terminal", host }),
makeTab({ id: "t2", type: "files", host }),
];
const payload = buildWorkspacePayload({
tabs,
activeTabId: "t1",
splitMode: "2-way",
paneTabIds: ["t1", "t2", null, null, null, null],
rowSizes: [100],
rowColSizes: [[50, 50]],
genSlotId,
});
expect(payload.version).toBe(1);
expect(payload.activeSlotId).toBe("slot-0");
expect(payload.paneTabIds).toEqual([
"slot-0",
"slot-1",
null,
null,
null,
null,
]);
expect(payload.splitMode).toBe("2-way");
expect(payload.rowSizes).toEqual([100]);
expect(payload.rowColSizes).toEqual([[50, 50]]);
});
it("sets activeSlotId to null when the active tab is not capturable", () => {
const payload = buildWorkspacePayload({
tabs: [makeTab({ id: "t1", type: "host-manager" })],
activeTabId: "t1",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.activeSlotId).toBeNull();
expect(payload.tabs).toHaveLength(0);
});
it("round-trips the sidebar arrangement", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
sidebar: {
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
},
});
expect(payload.sidebar).toEqual({
left: { view: "hosts", open: true, width: 320 },
right: { view: "history", open: true, width: 240 },
});
});
it("omits sidebar when not supplied so older payloads stay unchanged", () => {
const payload = buildWorkspacePayload({
tabs: [],
activeTabId: "dashboard",
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
});
expect(payload.sidebar).toBeUndefined();
expect(payload.version).toBe(1);
});
});
@@ -1,118 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const authApiMock = vi.hoisted(() => ({
get: vi.fn(async () => ({ data: [] })),
post: vi.fn(async () => ({ data: {} })),
patch: vi.fn(async () => ({ data: {} })),
put: vi.fn(async () => ({ data: {} })),
delete: vi.fn(async () => ({ data: { success: true } })),
}));
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
handleApiError: (error: unknown) => error,
}));
import {
listWorkspaces,
createWorkspace,
renameWorkspace,
updateWorkspaceContent,
deleteWorkspace,
duplicateWorkspace,
setDefaultWorkspace,
applyWorkspaceServer,
getLastSessionWorkspace,
saveLastSessionWorkspace,
} from "../../../../../plugins/workspaces/frontend/workspaces-api";
import type { WorkspacePayload } from "@/types/ui-types";
beforeEach(() => {
authApiMock.get.mockClear();
authApiMock.post.mockClear();
authApiMock.patch.mockClear();
authApiMock.put.mockClear();
authApiMock.delete.mockClear();
});
const samplePayload: WorkspacePayload = {
version: 1,
tabs: [],
activeSlotId: null,
splitMode: "none",
paneTabIds: [null, null, null, null, null, null],
rowSizes: [100],
rowColSizes: [[100]],
};
describe("workspaces-api", () => {
it("listWorkspaces GETs /workspaces", async () => {
authApiMock.get.mockResolvedValueOnce({ data: [{ id: 1 }] });
const result = await listWorkspaces();
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces");
expect(result).toEqual([{ id: 1 }]);
});
it("createWorkspace POSTs /workspaces with the payload", async () => {
await createWorkspace({
name: "Test A",
color: "#fff",
payload: samplePayload,
});
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces", {
name: "Test A",
color: "#fff",
payload: samplePayload,
});
});
it("renameWorkspace PATCHes /workspaces/:id", async () => {
await renameWorkspace(5, { name: "New Name" });
expect(authApiMock.patch).toHaveBeenCalledWith("/workspaces/5", {
name: "New Name",
});
});
it("updateWorkspaceContent PUTs /workspaces/:id/content", async () => {
await updateWorkspaceContent(5, samplePayload);
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/5/content", {
payload: samplePayload,
});
});
it("deleteWorkspace DELETEs /workspaces/:id", async () => {
await deleteWorkspace(5);
expect(authApiMock.delete).toHaveBeenCalledWith("/workspaces/5");
});
it("duplicateWorkspace POSTs /workspaces/:id/duplicate with a name", async () => {
await duplicateWorkspace(5, "Test A (copy)");
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/duplicate", {
name: "Test A (copy)",
});
});
it("setDefaultWorkspace POSTs /workspaces/:id/set-default", async () => {
await setDefaultWorkspace(5);
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/set-default");
});
it("applyWorkspaceServer POSTs /workspaces/:id/apply", async () => {
await applyWorkspaceServer(5);
expect(authApiMock.post).toHaveBeenCalledWith("/workspaces/5/apply");
});
it("getLastSessionWorkspace GETs /workspaces/last-session", async () => {
authApiMock.get.mockResolvedValueOnce({ data: null });
const result = await getLastSessionWorkspace();
expect(authApiMock.get).toHaveBeenCalledWith("/workspaces/last-session");
expect(result).toBeNull();
});
it("saveLastSessionWorkspace PUTs /workspaces/last-session", async () => {
await saveLastSessionWorkspace(samplePayload);
expect(authApiMock.put).toHaveBeenCalledWith("/workspaces/last-session", {
payload: samplePayload,
});
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import {
railItemLabel,
visibleRailItems,
} from "@/sidebar/rail-items";
import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/frontend/workspaceUtils";
import { WORKSPACE_CAPTURABLE_TYPES } from "../../../../plugins/workspaces/src/frontend/workspaceUtils";
import type { TabType } from "@/types/ui-types";
import en from "@/locales/en.json";