mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
fix: finish 2.9.0 plugin moves and get every check green
This commit is contained in:
1 parent
8c1e02af89
commit
6489b62ece
335 files changed
+29878
-17749
No files matched your search
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Every built plugin frontend in dist/plugins imports through the real loader
|
||||
* and runs activate(app) without an error. Needs `npm run build` first.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { FrontendModule } from "@termix/plugin-sdk/frontend";
|
||||
import type { PluginSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
configurePluginLoader,
|
||||
resetPluginLoader,
|
||||
syncPlugins,
|
||||
} from "@/plugin-host/loader";
|
||||
import { getPluginRecord } from "@/plugin-host/plugin-store";
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../..");
|
||||
const BUILT = path.join(REPO_ROOT, "dist", "plugins");
|
||||
|
||||
function pluginIds(): string[] {
|
||||
const root = path.join(REPO_ROOT, "plugins");
|
||||
return fs
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(root, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function manifest(id: string) {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUILT, id, "manifest.json"), "utf8"),
|
||||
) as {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
icon?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
contributes?: PluginSummary["contributes"];
|
||||
};
|
||||
}
|
||||
|
||||
function frontendFile(id: string): string {
|
||||
return path.join(BUILT, id, "dist", "frontend.js");
|
||||
}
|
||||
|
||||
/** What GET /plugins would answer for a running, enabled plugin. */
|
||||
function summaryFor(id: string): PluginSummary {
|
||||
const m = manifest(id);
|
||||
return {
|
||||
id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
enabled: true,
|
||||
state: "active",
|
||||
contributes: m.contributes ?? null,
|
||||
icon: m.icon,
|
||||
dependencies: m.dependencies ?? {},
|
||||
optionalDependencies: m.optionalDependencies ?? {},
|
||||
frontend: fs.existsSync(frontendFile(id)),
|
||||
assetVersion: "test",
|
||||
locales: ["en"],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: unknown[][] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const missing = pluginIds().filter(
|
||||
(id) => !fs.existsSync(path.join(BUILT, id, "manifest.json")),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing here talks to a server; a plugin that fetches on activate gets
|
||||
// an empty answer rather than a network error.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("{}", { status: 200 })),
|
||||
);
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
errors.push(args);
|
||||
});
|
||||
|
||||
configurePluginLoader({
|
||||
fetchPlugins: async () => pluginIds().map(summaryFor),
|
||||
importFrontend: async (summary) =>
|
||||
(await import(
|
||||
/* @vite-ignore */ pathToFileURL(frontendFile(summary.id)).href
|
||||
)) as FrontendModule,
|
||||
loadLocale: async (summary, file) => {
|
||||
if (file !== "en") return null;
|
||||
const locale = path.join(BUILT, summary.id, "locales", "en.json");
|
||||
return fs.existsSync(locale)
|
||||
? JSON.parse(fs.readFileSync(locale, "utf8"))
|
||||
: null;
|
||||
},
|
||||
injectCss: () => null,
|
||||
});
|
||||
|
||||
await syncPlugins();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await resetPluginLoader();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("built plugin frontends", () => {
|
||||
it("activates every plugin that ships a frontend", () => {
|
||||
const notActive = pluginIds()
|
||||
.filter((id) => fs.existsSync(frontendFile(id)))
|
||||
.map((id) => [
|
||||
id,
|
||||
getPluginRecord(id)?.frontend,
|
||||
getPluginRecord(id)?.error,
|
||||
])
|
||||
.filter(([, state]) => state !== "active");
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("logs no error while importing and activating them", () => {
|
||||
// Axios has no server to reach here; a failed request is the test
|
||||
// environment, not the plugin.
|
||||
const real = errors
|
||||
.map((args) => args.map(String).join(" "))
|
||||
.filter((line) => !/Network Error/.test(line));
|
||||
expect(real).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,6 @@ vi.mock("@/sidebar/UserProfilePanel", () => ({
|
||||
vi.mock("@/sidebar/AdminSettingsPanel", () => ({
|
||||
AdminSettingsPanel: () => <div>admin-panel</div>,
|
||||
}));
|
||||
vi.mock("@/settings/legacy-settings-components", () => ({
|
||||
registerLegacySettingsComponents: vi.fn(),
|
||||
}));
|
||||
|
||||
const { SettingsScreen } = await import("@/settings/SettingsScreen");
|
||||
|
||||
|
||||
@@ -42,15 +42,6 @@ function makeHost(overrides: Partial<Host>): Host {
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
authType: "credential",
|
||||
enableTerminal: true,
|
||||
enableTerminalToolbar: true,
|
||||
enableCommandHistory: true,
|
||||
enableTunnel: false,
|
||||
serverTunnels: [],
|
||||
enableFileManager: true,
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: false,
|
||||
enableTmuxMonitor: false,
|
||||
quickActions: [],
|
||||
enableSsh: true,
|
||||
sshPort: 22,
|
||||
|
||||
@@ -334,135 +334,18 @@ describe("sudo password persistence indicator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Proxmox / Proxmox Stats independent toggles", () => {
|
||||
it("defaults enableProxmoxStats to false with sane pollInterval/nodeName defaults", () => {
|
||||
const form = createHostEditorForm(null);
|
||||
|
||||
expect(form.enableProxmoxStats).toBe(false);
|
||||
expect(form.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 60,
|
||||
nodeName: null,
|
||||
});
|
||||
expect(form.enableProxmox).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds both configs independently from an existing host", () => {
|
||||
describe("plugin host settings", () => {
|
||||
it("keeps them on the form and out of the host payload", () => {
|
||||
const host = {
|
||||
pluginSettings: {
|
||||
proxmox: {
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: { windowsPatterns: "win", dockerPatterns: "docker" },
|
||||
enableProxmoxStats: true,
|
||||
proxmoxStatsConfig: { pollInterval: 30, nodeName: "pve-custom" },
|
||||
},
|
||||
},
|
||||
pluginSettings: { proxmox: { enableProxmox: true } },
|
||||
} as unknown as Host;
|
||||
|
||||
const form = createHostEditorForm(host);
|
||||
|
||||
expect(form.enableProxmox).toBe(true);
|
||||
expect(form.enableProxmoxStats).toBe(true);
|
||||
expect(form.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 30,
|
||||
nodeName: "pve-custom",
|
||||
});
|
||||
});
|
||||
|
||||
it("nulls proxmoxStatsConfig in the payload when enableProxmoxStats is off, regardless of enableProxmox", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: {
|
||||
defaultCredentialId: null,
|
||||
defaultAuthType: "password",
|
||||
windowsPatterns: "win",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "",
|
||||
},
|
||||
enableProxmoxStats: false,
|
||||
proxmoxStatsConfig: { pollInterval: 45, nodeName: "leftover" },
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(true);
|
||||
expect(payload.proxmoxConfig).not.toBeNull();
|
||||
expect(payload.enableProxmoxStats).toBe(false);
|
||||
expect(payload.proxmoxStatsConfig).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps proxmoxConfig null in the payload when enableProxmox is off, even though enableProxmoxStats is on", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: true,
|
||||
proxmoxStatsConfig: { pollInterval: 90, nodeName: "pve1" },
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(false);
|
||||
expect(payload.proxmoxConfig).toBeNull();
|
||||
expect(payload.enableProxmoxStats).toBe(true);
|
||||
expect(payload.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 90,
|
||||
nodeName: "pve1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the source identity when editing an imported Proxmox guest", () => {
|
||||
const source = {
|
||||
source: "proxmox" as const,
|
||||
sourceHostId: 7,
|
||||
node: "pve1",
|
||||
vmid: 101,
|
||||
type: "qemu" as const,
|
||||
lastSeenAt: "2026-08-14T00:00:00.000Z",
|
||||
lastStatus: "running",
|
||||
missingSince: null,
|
||||
};
|
||||
const form = {
|
||||
...createHostEditorForm({
|
||||
pluginSettings: {
|
||||
proxmox: {
|
||||
enableProxmox: false,
|
||||
proxmoxConfig: { source },
|
||||
},
|
||||
},
|
||||
} as unknown as Host),
|
||||
name: "Edited guest",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(false);
|
||||
expect(payload.proxmoxConfig).toEqual({ source });
|
||||
});
|
||||
|
||||
it("sends both configs when both toggles are on", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
enableProxmox: true,
|
||||
proxmoxConfig: {
|
||||
defaultCredentialId: null,
|
||||
defaultAuthType: "password",
|
||||
windowsPatterns: "win",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "",
|
||||
},
|
||||
enableProxmoxStats: true,
|
||||
proxmoxStatsConfig: { pollInterval: 60, nodeName: null },
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.enableProxmox).toBe(true);
|
||||
expect(payload.proxmoxConfig).not.toBeNull();
|
||||
expect(payload.enableProxmoxStats).toBe(true);
|
||||
expect(payload.proxmoxStatsConfig).toEqual({
|
||||
pollInterval: 60,
|
||||
nodeName: null,
|
||||
});
|
||||
expect(form.pluginSettings).toEqual({ proxmox: { enableProxmox: true } });
|
||||
expect(payload).not.toHaveProperty("enableProxmox");
|
||||
expect(payload).not.toHaveProperty("pluginSettings");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,20 +45,6 @@ function sshHost(
|
||||
pin: false,
|
||||
authType: "password",
|
||||
connectionType: "ssh",
|
||||
enableTerminal: true,
|
||||
enableSessionLogging: false,
|
||||
enableCommandHistory: false,
|
||||
enableTunnel: false,
|
||||
enableFileManager: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: true,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: false,
|
||||
showTunnelInSidebar: false,
|
||||
showDockerInSidebar: false,
|
||||
showServerStatsInSidebar: false,
|
||||
defaultPath: "",
|
||||
tunnelConnections: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
status: "online",
|
||||
@@ -76,9 +62,13 @@ function rawPayload() {
|
||||
port: 22,
|
||||
username: "deploy",
|
||||
password: "hunter2",
|
||||
guacamoleConfig: {
|
||||
"gateway-hostname": "gw.example.com",
|
||||
"gateway-password": "gw-secret",
|
||||
pluginSettings: {
|
||||
"remote-desktop": {
|
||||
guacamoleConfig: {
|
||||
"gateway-hostname": "gw.example.com",
|
||||
"gateway-password": "gw-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -207,6 +197,10 @@ describe("HostExportDialog - credential handling", () => {
|
||||
expect(text).toContain("gw.example.com");
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
expect(parsed.hosts[0].guacamoleConfig["gateway-password"]).toBeNull();
|
||||
expect(
|
||||
parsed.hosts[0].pluginSettings["remote-desktop"].guacamoleConfig[
|
||||
"gateway-password"
|
||||
],
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,14 @@ function shareRaw(): ExportPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayConfig(host: Record<string, unknown>): Record<string, unknown> {
|
||||
const plugins = host.pluginSettings as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
return plugins["remote-desktop"].guacamoleConfig as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function rdpRaw(): ExportPayload {
|
||||
return {
|
||||
hosts: [
|
||||
@@ -72,9 +80,13 @@ function rdpRaw(): ExportPayload {
|
||||
port: 3389,
|
||||
username: "admin",
|
||||
password: null,
|
||||
guacamoleConfig: {
|
||||
"gateway-hostname": "gw.example.com",
|
||||
"gateway-password": "gw-secret",
|
||||
pluginSettings: {
|
||||
"remote-desktop": {
|
||||
guacamoleConfig: {
|
||||
"gateway-hostname": "gw.example.com",
|
||||
"gateway-password": "gw-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -198,21 +210,21 @@ describe("buildExportPayload", () => {
|
||||
|
||||
it("nulls the guacamole gateway password when credentials are excluded", () => {
|
||||
const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, false);
|
||||
const config = out.hosts[0].guacamoleConfig as Record<string, unknown>;
|
||||
const config = gatewayConfig(out.hosts[0]);
|
||||
expect(config["gateway-password"]).toBeNull();
|
||||
expect(config["gateway-hostname"]).toBe("gw.example.com");
|
||||
});
|
||||
|
||||
it("keeps the guacamole gateway password when credentials are included", () => {
|
||||
const out = buildExportPayload(rdpRaw(), null, ALL_GROUPS, true);
|
||||
const config = out.hosts[0].guacamoleConfig as Record<string, unknown>;
|
||||
const config = gatewayConfig(out.hosts[0]);
|
||||
expect(config["gateway-password"]).toBe("gw-secret");
|
||||
});
|
||||
|
||||
it("does not mutate the source payload when nulling nested secrets", () => {
|
||||
const raw = rdpRaw();
|
||||
buildExportPayload(raw, null, ALL_GROUPS, false);
|
||||
const config = raw.hosts[0].guacamoleConfig as Record<string, unknown>;
|
||||
const config = gatewayConfig(raw.hosts[0]);
|
||||
expect(config["gateway-password"]).toBe("gw-secret");
|
||||
});
|
||||
|
||||
@@ -260,7 +272,7 @@ describe("maskSecrets", () => {
|
||||
|
||||
it("masks the guacamole gateway password", () => {
|
||||
const out = maskSecrets(rdpRaw());
|
||||
const config = out.hosts[0].guacamoleConfig as Record<string, unknown>;
|
||||
const config = gatewayConfig(out.hosts[0]);
|
||||
expect(config["gateway-password"]).toBe("<included>");
|
||||
expect(config["gateway-hostname"]).toBe("gw.example.com");
|
||||
expect(JSON.stringify(out)).not.toContain("gw-secret");
|
||||
|
||||
@@ -25,9 +25,6 @@ function host(id: string, name: string): Host {
|
||||
authType: "password",
|
||||
pin: false,
|
||||
enableSsh: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
quickActions: [],
|
||||
} as Host;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
hasUiOverrides,
|
||||
PRESETS,
|
||||
resolveArea,
|
||||
resolvePluginArea,
|
||||
sanitizeUiOverrides,
|
||||
sanitizeUiPreferences,
|
||||
UI_PREFERENCES_VERSION,
|
||||
@@ -100,14 +101,6 @@ describe("sanitizeUiOverrides", () => {
|
||||
expect(result.rail?.hiddenTabs).toEqual(["serial", "history"]);
|
||||
});
|
||||
|
||||
it("clamps and rounds integer knobs, dropping out-of-range values", () => {
|
||||
expect(sanitizeUiOverrides({ hostMetrics: { columns: 2.4 } })).toEqual({
|
||||
hostMetrics: { columns: 2 },
|
||||
});
|
||||
expect(sanitizeUiOverrides({ hostMetrics: { columns: 9 } })).toEqual({});
|
||||
expect(sanitizeUiOverrides({ hostMetrics: { columns: 0 } })).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps an explicit null for nullable array knobs", () => {
|
||||
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
|
||||
{
|
||||
@@ -161,7 +154,6 @@ describe("PRESETS.balanced", () => {
|
||||
expect(PRESETS.balanced.rail.hiddenTabs).toEqual([]);
|
||||
expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled");
|
||||
expect(PRESETS.balanced.fileManager.viewMode).toBe("grid");
|
||||
expect(PRESETS.balanced.docker.viewMode).toBe("list");
|
||||
expect(PRESETS.balanced.hostEditor.mode).toBe("full");
|
||||
expect(PRESETS.balanced.homepage.enabledWidgets).toBeNull();
|
||||
});
|
||||
@@ -184,3 +176,48 @@ describe("PRESETS.balanced", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin areas", () => {
|
||||
it("moves version 1 docker and host metrics overrides to their plugins", () => {
|
||||
const upgraded = sanitizeUiPreferences({
|
||||
version: 1,
|
||||
preset: "balanced",
|
||||
overrides: {
|
||||
docker: { containerLayout: "table" },
|
||||
hostMetrics: { columns: 2 },
|
||||
},
|
||||
});
|
||||
expect(upgraded.overrides).toEqual({
|
||||
"plugin:docker": { containerLayout: "table" },
|
||||
"plugin:host-metrics": { columns: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a plugin area from its presets and the user's overrides", () => {
|
||||
const presets = {
|
||||
simple: { columns: 1 },
|
||||
balanced: { columns: 3 },
|
||||
advanced: { columns: 4 },
|
||||
};
|
||||
const preferences = sanitizeUiPreferences({
|
||||
preset: "advanced",
|
||||
overrides: { "plugin:host-metrics": { columns: 2 } },
|
||||
});
|
||||
expect(resolvePluginArea(preferences, "host-metrics", presets)).toEqual({
|
||||
columns: 2,
|
||||
});
|
||||
expect(resolvePluginArea(preferences, "docker", presets)).toEqual({
|
||||
columns: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps only plain values in a plugin area", () => {
|
||||
const { overrides } = sanitizeUiPreferences({
|
||||
overrides: {
|
||||
"plugin:docker": { ok: "card", nested: { no: 1 }, list: ["a", 2] },
|
||||
"plugin:Bad_Id": { x: 1 },
|
||||
},
|
||||
});
|
||||
expect(overrides).toEqual({ "plugin:docker": { ok: "card" } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user