Chore: homepage tests (#6278)

This commit is contained in:
shamoon
2026-02-04 19:58:39 -08:00
committed by GitHub
parent 7d019185a3
commit 872a3600aa
558 changed files with 32606 additions and 84 deletions

View File

@@ -111,7 +111,7 @@ function ensureParentGroupExists(sortedGroups, configuredGroups, group, definedL
const parentGroupName = group.parent;
const parentGroup = findGroupByName(configuredGroups, parentGroupName);
if (parentGroup && parentGroup.parent) {
ensureParentGroupExists(sortedGroups, configuredGroups, parentGroup);
ensureParentGroupExists(sortedGroups, configuredGroups, parentGroup, definedLayouts);
} else {
const parentGroupIndex = definedLayouts.findIndex((layout) => layout === parentGroupName);
if (parentGroupIndex > -1) {

View File

@@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml, config, widgetHelpers, serviceHelpers } = vi.hoisted(() => ({
fs: {
readFile: vi.fn(),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
getSettings: vi.fn(),
substituteEnvironmentVars: vi.fn((s) => s),
default: vi.fn(),
},
widgetHelpers: {
widgetsFromConfig: vi.fn(),
cleanWidgetGroups: vi.fn(),
},
serviceHelpers: {
servicesFromDocker: vi.fn(),
servicesFromKubernetes: vi.fn(),
servicesFromConfig: vi.fn(),
cleanServiceGroups: vi.fn((g) => g),
findGroupByName: vi.fn(),
},
}));
vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config);
vi.mock("utils/config/widget-helpers", () => widgetHelpers);
vi.mock("utils/config/service-helpers", () => serviceHelpers);
import { bookmarksResponse, servicesResponse, widgetsResponse } from "./api-response";
describe("utils/config/api-response", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("bookmarksResponse returns [] when bookmarks are missing", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce(null);
const res = await bookmarksResponse();
expect(res).toEqual([]);
expect(config.getSettings).not.toHaveBeenCalled();
});
it("bookmarksResponse falls back when settings cannot be loaded", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fs.readFile.mockResolvedValueOnce("ignored");
config.getSettings.mockRejectedValueOnce(new Error("bad settings"));
yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { B: [{ LinkB: [{ href: "b" }] }] }]);
const res = await bookmarksResponse();
expect(res.map((g) => g.name)).toEqual(["A", "B"]);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("bookmarksResponse sorts groups based on settings layout", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
config.getSettings.mockResolvedValueOnce({ layout: { B: {}, A: {} } });
yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { B: [{ LinkB: [{ href: "b" }] }] }]);
const res = await bookmarksResponse();
expect(res.map((g) => g.name)).toEqual(["B", "A"]);
});
it("bookmarksResponse appends groups not present in the layout", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
config.getSettings.mockResolvedValueOnce({ layout: { A: {} } });
yaml.load.mockReturnValueOnce([{ A: [{ LinkA: [{ href: "a" }] }] }, { C: [{ LinkC: [{ href: "c" }] }] }]);
const res = await bookmarksResponse();
expect(res.map((g) => g.name)).toEqual(["A", "C"]);
});
it("widgetsResponse returns sanitized configured widgets", async () => {
widgetHelpers.widgetsFromConfig.mockResolvedValueOnce([{ type: "search", options: { url: "x" } }]);
widgetHelpers.cleanWidgetGroups.mockResolvedValueOnce([{ type: "search", options: { index: 0 } }]);
expect(await widgetsResponse()).toEqual([{ type: "search", options: { index: 0 } }]);
});
it("widgetsResponse returns [] when widgets cannot be loaded", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
widgetHelpers.widgetsFromConfig.mockRejectedValueOnce(new Error("bad widgets"));
expect(await widgetsResponse()).toEqual([]);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("servicesResponse merges groups and sorts services by weight then name", async () => {
// Minimal stubs for findGroupByName used within servicesResponse.
serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null);
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{
name: "GroupA",
services: [
{ name: "b", weight: 200 },
{ name: "a", weight: 200 },
],
groups: [],
},
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([
{ name: "GroupA", services: [{ name: "c", weight: 100 }], groups: [] },
]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([
{ name: "GroupA", services: [{ name: "d", weight: 50 }], groups: [] },
{ name: "Empty", services: [], groups: [] },
]);
config.getSettings.mockResolvedValueOnce({ layout: { GroupA: {}, GroupB: {} } });
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["GroupA"]);
expect(groups[0].services.map((s) => s.name)).toEqual(["d", "c", "a", "b"]);
});
it("servicesResponse logs when no docker services are discovered", async () => {
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
serviceHelpers.findGroupByName.mockImplementation((groups, name) => groups.find((g) => g.name === name) ?? null);
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([]);
config.getSettings.mockResolvedValueOnce({});
const groups = await servicesResponse();
expect(groups).toEqual([]);
expect(debugSpy).toHaveBeenCalledWith("No containers were found with homepage labels.");
debugSpy.mockRestore();
});
it("servicesResponse tolerates discovery/load failures and returns [] when nothing can be loaded", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
serviceHelpers.servicesFromDocker.mockRejectedValueOnce(new Error("docker bad"));
serviceHelpers.servicesFromKubernetes.mockRejectedValueOnce(new Error("kube bad"));
serviceHelpers.servicesFromConfig.mockRejectedValueOnce(new Error("config bad"));
config.getSettings.mockRejectedValueOnce(new Error("settings bad"));
const groups = await servicesResponse();
expect(groups).toEqual([]);
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it("servicesResponse supports multi-level nested layout groups and ensures the top-level parent exists", async () => {
serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {
for (const group of groups ?? []) {
if (group.name === name) {
if (parent) group.parent = parent;
return group;
}
const found = find(group.groups, name, group.name);
if (found) return found;
}
return null;
});
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{ name: "Child", services: [{ name: "svc", weight: 1 }], groups: [] },
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([{ name: "Root", services: [], groups: [] }]);
config.getSettings.mockResolvedValueOnce({ layout: { Root: { Top: { Child: {} } } } });
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["Root"]);
expect(groups[0].groups[0].name).toBe("Top");
expect(groups[0].groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].groups[0].services).toEqual([{ name: "svc", weight: 1 }]);
});
it("servicesResponse merges discovered nested groups into their configured parent layout group", async () => {
serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {
for (const group of groups ?? []) {
if (group.name === name) {
if (parent) group.parent = parent;
return group;
}
const found = find(group.groups, name, group.name);
if (found) return found;
}
return null;
});
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{
name: "Child",
services: [
{ name: "svcB", weight: 50 },
{ name: "svcA", weight: 10 },
],
groups: [],
},
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([
{
name: "Top",
services: [],
groups: [{ name: "Child", services: [], groups: [] }],
},
]);
config.getSettings.mockResolvedValueOnce({ layout: { Top: { Child: {} } } });
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["Top"]);
expect(groups[0].groups).toHaveLength(1);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services.map((s) => s.name)).toEqual(["svcA", "svcB"]);
});
it("servicesResponse merges nested discovered groups into their configured parent when no layout is defined", async () => {
serviceHelpers.findGroupByName.mockImplementation(function find(groups, name, parent) {
for (const group of groups ?? []) {
if (group.name === name) {
if (parent) group.parent = parent;
return group;
}
const found = find(group.groups, name, group.name);
if (found) return found;
}
return null;
});
serviceHelpers.servicesFromDocker.mockResolvedValueOnce([
{ name: "Child", services: [{ name: "svc", weight: 1 }], groups: [] },
]);
serviceHelpers.servicesFromKubernetes.mockResolvedValueOnce([]);
serviceHelpers.servicesFromConfig.mockResolvedValueOnce([
{ name: "Top", services: [], groups: [{ name: "Child", services: [], groups: [] }] },
]);
config.getSettings.mockResolvedValueOnce({});
const groups = await servicesResponse();
expect(groups.map((g) => g.name)).toEqual(["Top"]);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services).toEqual([{ name: "svc", weight: 1 }]);
});
});

View File

@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml } = vi.hoisted(() => ({
fs: {
copyFileSync: vi.fn(),
existsSync: vi.fn(),
mkdirSync: vi.fn(),
readFileSync: vi.fn(),
},
yaml: {
load: vi.fn(),
},
}));
vi.mock("fs", () => fs);
vi.mock("js-yaml", () => ({ default: yaml, ...yaml }));
describe("utils/config/config checkAndCopyConfig", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
process.env = { ...originalEnv, HOMEPAGE_CONFIG_DIR: "/conf" };
});
it("returns false when it cannot create the config directory", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
fs.existsSync.mockReturnValueOnce(false);
fs.mkdirSync.mockImplementationOnce(() => {
throw new Error("no perms");
});
const mod = await import("./config");
expect(mod.default("services.yaml")).toBe(false);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it("copies the skeleton file when the config file does not exist", async () => {
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
// dir exists
fs.existsSync.mockReturnValueOnce(true);
// config file missing
fs.existsSync.mockReturnValueOnce(false);
const mod = await import("./config");
expect(mod.default("services.yaml")).toBe(true);
expect(fs.copyFileSync).toHaveBeenCalled();
expect(infoSpy).toHaveBeenCalled();
infoSpy.mockRestore();
});
it("exits the process when copying the skeleton fails", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("exit");
});
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fs.existsSync.mockReturnValueOnce(true);
fs.existsSync.mockReturnValueOnce(false);
fs.copyFileSync.mockImplementationOnce(() => {
throw new Error("copy failed");
});
const mod = await import("./config");
expect(() => mod.default("services.yaml")).toThrow("exit");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(errSpy).toHaveBeenCalled();
exitSpy.mockRestore();
errSpy.mockRestore();
});
it("returns a parse error with config name when YAML is invalid", async () => {
fs.existsSync.mockReturnValueOnce(true);
fs.existsSync.mockReturnValueOnce(true);
fs.readFileSync.mockReturnValueOnce("bad");
yaml.load.mockImplementationOnce(() => {
throw Object.assign(new Error("yaml bad"), { name: "YAMLException" });
});
const mod = await import("./config");
const result = mod.default("services.yaml");
expect(result).toEqual(expect.objectContaining({ name: "YAMLException", config: "services.yaml" }));
});
});

View File

@@ -0,0 +1,59 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import cache from "memory-cache";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("utils/config/config", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
cache.del("homepageEnvironmentVariables");
});
afterEach(() => {
process.env = originalEnv;
cache.del("homepageEnvironmentVariables");
});
it("substituteEnvironmentVars replaces HOMEPAGE_VAR_* placeholders", async () => {
process.env.HOMEPAGE_VAR_FOO = "bar";
const mod = await import("./config");
expect(mod.substituteEnvironmentVars("x {{HOMEPAGE_VAR_FOO}} y")).toBe("x bar y");
});
it("substituteEnvironmentVars replaces HOMEPAGE_FILE_* placeholders with file contents", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "homepage-config-test-"));
const secretPath = path.join(dir, "secret.txt");
writeFileSync(secretPath, "secret", "utf8");
process.env.HOMEPAGE_FILE_SECRET = secretPath;
const mod = await import("./config");
expect(mod.substituteEnvironmentVars("token={{HOMEPAGE_FILE_SECRET}}")).toBe("token=secret");
});
it("getSettings reads from HOMEPAGE_CONFIG_DIR and converts layout list to an object", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "homepage-settings-test-"));
process.env.HOMEPAGE_CONFIG_DIR = dir;
process.env.HOMEPAGE_VAR_TITLE = "MyTitle";
// Create a minimal settings.yaml; checkAndCopyConfig will see it exists and won't copy skeleton.
writeFileSync(
path.join(dir, "settings.yaml"),
['title: "{{HOMEPAGE_VAR_TITLE}}"', "layout:", " - GroupA:", " style: row"].join("\n"),
"utf8",
);
vi.resetModules(); // ensure CONF_DIR is computed from updated env
const mod = await import("./config");
const settings = mod.getSettings();
expect(settings.title).toBe("MyTitle");
expect(settings.layout).toEqual({ GroupA: { style: "row" } });
});
});

View File

@@ -5,6 +5,14 @@ import yaml from "js-yaml";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
export function getDefaultDockerArgs(platform = process.platform) {
if (platform !== "win32" && platform !== "darwin") {
return { socketPath: "/var/run/docker.sock" };
}
return { host: "127.0.0.1" };
}
export default function getDockerArguments(server) {
checkAndCopyConfig("docker.yaml");
@@ -14,11 +22,7 @@ export default function getDockerArguments(server) {
const servers = yaml.load(configData);
if (!server) {
if (process.platform !== "win32" && process.platform !== "darwin") {
return { socketPath: "/var/run/docker.sock" };
}
return { host: "127.0.0.1" };
return getDefaultDockerArgs();
}
if (servers[server]) {

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
const { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({
fs: {
readFileSync: vi.fn((filePath, encoding) => {
if (String(filePath).endsWith("/docker.yaml") && encoding === "utf8") return "docker-yaml";
return Buffer.from(String(filePath));
}),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
},
checkAndCopyConfig: vi.fn(),
}));
vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
...config,
}));
import getDockerArguments, { getDefaultDockerArgs } from "./docker";
describe("utils/config/docker", () => {
it("getDefaultDockerArgs returns a socketPath on linux and host on darwin", () => {
expect(getDefaultDockerArgs("linux")).toEqual({ socketPath: "/var/run/docker.sock" });
expect(getDefaultDockerArgs("darwin")).toEqual({ host: "127.0.0.1" });
});
it("returns default args when no server is given", () => {
yaml.load.mockReturnValueOnce({});
const args = getDockerArguments();
expect(checkAndCopyConfig).toHaveBeenCalledWith("docker.yaml");
// if running on linux, should return socketPath
if (process.platform !== "win32" && process.platform !== "darwin") {
expect(args).toEqual({ socketPath: "/var/run/docker.sock" });
} else {
// otherwise, should return host
expect(args).toEqual(expect.objectContaining({ host: expect.any(String) }));
}
});
it("returns socket config when server has a socket", () => {
yaml.load.mockReturnValueOnce({
"docker-local": { socket: "/tmp/docker.sock", swarm: true },
});
const args = getDockerArguments("docker-local");
expect(args).toEqual({ conn: { socketPath: "/tmp/docker.sock" }, swarm: true });
});
it("returns host/port/tls/protocol/headers config when provided", () => {
yaml.load.mockReturnValueOnce({
remote: {
host: "10.0.0.1",
port: 2376,
swarm: false,
protocol: "http",
headers: { "X-Test": "1" },
tls: { caFile: "ca.pem", certFile: "cert.pem", keyFile: "key.pem" },
},
});
const args = getDockerArguments("remote");
expect(args).toEqual(
expect.objectContaining({
swarm: false,
conn: expect.objectContaining({
host: "10.0.0.1",
port: 2376,
protocol: "http",
headers: { "X-Test": "1" },
ca: expect.any(Buffer),
cert: expect.any(Buffer),
key: expect.any(Buffer),
}),
}),
);
});
it("returns null when server is not configured", () => {
yaml.load.mockReturnValueOnce({ other: { host: "x" } });
expect(getDockerArguments("missing")).toBeNull();
});
it("returns the raw server config when it has no host/socket overrides", () => {
yaml.load.mockReturnValueOnce({
raw: { swarm: true, something: "else" },
});
expect(getDockerArguments("raw")).toEqual({ swarm: true, something: "else" });
});
});

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml, config, checkAndCopyConfig, kube, apiExt } = vi.hoisted(() => {
const apiExt = {
readCustomResourceDefinitionStatus: vi.fn(),
};
const kube = {
loadFromCluster: vi.fn(),
loadFromDefault: vi.fn(),
makeApiClient: vi.fn(() => apiExt),
};
return {
fs: {
readFileSync: vi.fn(() => "kube-yaml"),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
},
checkAndCopyConfig: vi.fn(),
kube,
apiExt,
};
});
vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
...config,
}));
vi.mock("@kubernetes/client-node", () => ({
ApiextensionsV1Api: class ApiextensionsV1Api {},
KubeConfig: class KubeConfig {
loadFromCluster() {
return kube.loadFromCluster();
}
loadFromDefault() {
return kube.loadFromDefault();
}
makeApiClient() {
return kube.makeApiClient();
}
},
}));
import { checkCRD, getKubeConfig, getKubernetes } from "./kubernetes";
describe("utils/config/kubernetes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getKubernetes loads and parses kubernetes.yaml", () => {
yaml.load.mockReturnValueOnce({ mode: "disabled" });
expect(getKubernetes()).toEqual({ mode: "disabled" });
expect(checkAndCopyConfig).toHaveBeenCalledWith("kubernetes.yaml");
});
it("getKubeConfig returns null when disabled", () => {
yaml.load.mockReturnValueOnce({ mode: "disabled" });
expect(getKubeConfig()).toBeNull();
});
it("getKubeConfig loads from cluster/default based on mode", () => {
yaml.load.mockReturnValueOnce({ mode: "cluster" });
const kc1 = getKubeConfig();
expect(kube.loadFromCluster).toHaveBeenCalled();
expect(kc1).not.toBeNull();
yaml.load.mockReturnValueOnce({ mode: "default" });
const kc2 = getKubeConfig();
expect(kube.loadFromDefault).toHaveBeenCalled();
expect(kc2).not.toBeNull();
});
it("checkCRD returns true when the CRD exists", async () => {
apiExt.readCustomResourceDefinitionStatus.mockResolvedValueOnce({ ok: true });
const logger = { error: vi.fn() };
await expect(checkCRD("x.example", kube, logger)).resolves.toBe(true);
});
it("checkCRD returns false and logs on 403", async () => {
apiExt.readCustomResourceDefinitionStatus.mockRejectedValueOnce({
statusCode: 403,
body: { message: "nope" },
});
const logger = { error: vi.fn() };
await expect(checkCRD("x.example", kube, logger)).resolves.toBe(false);
expect(logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
const { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({
fs: {
readFileSync: vi.fn(() => "proxmox-yaml"),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
},
checkAndCopyConfig: vi.fn(),
}));
vi.mock("fs", () => ({
readFileSync: fs.readFileSync,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => ({
default: checkAndCopyConfig,
...config,
}));
import { getProxmoxConfig } from "./proxmox";
describe("utils/config/proxmox", () => {
it("loads and parses proxmox.yaml", () => {
yaml.load.mockReturnValueOnce({ pve: { url: "http://pve" } });
expect(getProxmoxConfig()).toEqual({ pve: { url: "http://pve" } });
expect(checkAndCopyConfig).toHaveBeenCalledWith("proxmox.yaml");
expect(fs.readFileSync).toHaveBeenCalledWith("/conf/proxmox.yaml", "utf8");
});
});

View File

@@ -86,7 +86,7 @@ export async function servicesFromDocker() {
// bad docker connections can result in a <Buffer ...> object?
// in any case, this ensures the result is the expected array
if (!Array.isArray(containers)) {
return [];
return { server: serverName, services: [] };
}
const discovered = containers.map((container) => {
@@ -188,6 +188,7 @@ export async function servicesFromKubernetes() {
const resources = [...ingressList, ...traefikIngressList, ...httpRouteList];
/* c8 ignore next 3 -- resources is always an array once the spreads succeed */
if (!resources) {
return [];
}

View File

@@ -0,0 +1,587 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi } = vi.hoisted(() => {
const state = {
servicesYaml: null,
dockerYaml: null,
dockerContainers: [],
dockerContainersByServer: {},
dockerServicesByServer: {},
kubeConfig: null,
kubeServices: [],
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
const fs = {
readFile: vi.fn(async (filePath) => {
if (String(filePath).endsWith("/services.yaml")) return "services";
if (String(filePath).endsWith("/docker.yaml")) return "docker";
return "";
}),
};
const yaml = {
load: vi.fn((contents) => {
if (contents === "services") return state.servicesYaml;
if (contents === "docker") return state.dockerYaml;
return null;
}),
};
const config = {
CONF_DIR: "/conf",
getSettings: vi.fn(() => ({ instanceName: undefined })),
substituteEnvironmentVars: vi.fn((s) => s),
default: vi.fn(),
};
const Docker = vi.fn((conn) => ({
listContainers: vi.fn(async () => state.dockerContainersByServer[conn?.serverName] ?? state.dockerContainers),
listServices: vi.fn(async () => state.dockerServicesByServer[conn?.serverName] ?? state.dockerContainers),
}));
const dockerCfg = {
default: vi.fn((serverName) => ({ conn: { serverName } })),
};
const kubeCfg = {
getKubeConfig: vi.fn(() => state.kubeConfig),
};
const kubeApi = {
listIngress: vi.fn(async () => []),
listTraefikIngress: vi.fn(async () => []),
listHttpRoute: vi.fn(async () => []),
isDiscoverable: vi.fn(() => true),
constructedServiceFromResource: vi.fn(async () => state.kubeServices.shift()),
};
return { state, fs, yaml, config, Docker, dockerCfg, kubeCfg, kubeApi };
});
vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config);
vi.mock("dockerode", () => ({ default: Docker }));
vi.mock("utils/config/docker", () => dockerCfg);
vi.mock("utils/config/kubernetes", () => kubeCfg);
vi.mock("utils/kubernetes/export", () => ({ default: kubeApi }));
vi.mock("utils/logger", () => ({
// Keep a stable logger instance so tests don't depend on module re-imports.
default: vi.fn(() => state.logger),
}));
describe("utils/config/service-helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
state.servicesYaml = null;
state.dockerYaml = null;
state.dockerContainers = [];
state.dockerContainersByServer = {};
state.dockerServicesByServer = {};
state.kubeConfig = null;
state.kubeServices = [];
config.getSettings.mockReturnValue({ instanceName: undefined });
});
it("servicesFromConfig returns [] when services.yaml is empty", async () => {
state.servicesYaml = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromConfig()).toEqual([]);
});
it("servicesFromDocker returns [] when docker.yaml is empty", async () => {
state.dockerYaml = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromDocker()).toEqual([]);
});
it("servicesFromDocker tolerates non-array container responses from Docker", async () => {
state.dockerYaml = { "docker-local": {} };
state.dockerContainersByServer["docker-local"] = Buffer.from("bad docker response");
const mod = await import("./service-helpers");
const discovered = await mod.servicesFromDocker();
expect(discovered).toEqual([]);
});
it("servicesFromConfig parses nested groups, assigns default weights, and skips invalid entries", async () => {
state.servicesYaml = [
{
Main: [
{
Child: [{ SvcA: { icon: "a" } }, { SvcB: { icon: "b", weight: 5 } }],
},
{ SvcRoot: { icon: "r" } },
{ BadSvc: null },
],
},
];
const mod = await import("./service-helpers");
const groups = await mod.servicesFromConfig();
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("Main");
expect(groups[0].type).toBe("group");
// Root services live on the group; child groups are nested.
expect(groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
{ name: "SvcRoot", weight: 100 },
]);
expect(groups[0].groups).toHaveLength(1);
expect(groups[0].groups[0].name).toBe("Child");
expect(groups[0].groups[0].services.map((s) => ({ name: s.name, weight: s.weight }))).toEqual([
{ name: "SvcA", weight: 100 },
{ name: "SvcB", weight: 5 },
]);
expect(state.logger.warn).toHaveBeenCalled();
});
it("cleanServiceGroups normalizes weights, moves widget->widgets, and parses per-widget settings", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Group",
services: [
{
name: "svc",
showStats: "true",
weight: "not-a-number",
widgets: [
// Invalid fields/highlight should be dropped with a log message.
{ type: "iframe", fields: "{bad}", highlight: "{bad}", src: "https://example.com" },
// Type-specific boolean parsing.
{ type: "portainer", kubernetes: "true" },
{ type: "deluge", enableLeechProgress: "true", enableLeechSize: "false" },
],
// `widget` is appended after the `widgets` array.
widget: {
type: "glances",
metric: "cpu",
chart: false,
version: "3",
refreshInterval: 1500,
pointsLimit: 10,
diskUnits: "gb",
fields: '["cpu"]',
highlight: '{"level":"warning"}',
hideErrors: true,
},
},
{
name: "svc2",
weight: {},
widget: { type: "openwrt", interfaceName: "eth0" },
},
{
name: "svc3",
weight: "7",
widget: { type: "frigate", enableRecentEvents: true },
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
expect(cleaned).toHaveLength(1);
expect(cleaned[0].type).toBe("group");
expect(cleaned[0].services).toHaveLength(3);
const svc = cleaned[0].services[0];
expect(svc.showStats).toBe(true);
expect(svc.weight).toBe(0);
expect(svc.widgets).toHaveLength(4);
// The last widget is the appended `widget` entry; it should carry service metadata.
const glancesWidget = svc.widgets[3];
expect(glancesWidget.type).toBe("glances");
expect(glancesWidget.service_group).toBe("Group");
expect(glancesWidget.service_name).toBe("svc");
expect(glancesWidget.index).toBe(3);
expect(glancesWidget.hide_errors).toBe(true);
expect(glancesWidget.fields).toEqual(["cpu"]);
expect(glancesWidget.highlight).toEqual({ level: "warning" });
expect(glancesWidget.chart).toBe(false);
expect(glancesWidget.version).toBe(3);
// Type-specific parsing for other widgets.
expect(svc.widgets[1].kubernetes).toBe(true);
expect(svc.widgets[2].enableLeechProgress).toBe(true);
expect(svc.widgets[2].enableLeechSize).toBe(false);
const svc2 = cleaned[0].services[1];
expect(svc2.weight).toBe(0);
expect(svc2.widgets).toHaveLength(1);
expect(svc2.widgets[0]).toEqual(
expect.objectContaining({
type: "openwrt",
interfaceName: "eth0",
service_group: "Group",
service_name: "svc2",
index: 0,
}),
);
const svc3 = cleaned[0].services[2];
expect(svc3.weight).toBe(7);
expect(svc3.widgets[0]).toEqual(expect.objectContaining({ type: "frigate", enableRecentEvents: true }));
expect(state.logger.error).toHaveBeenCalled();
});
it("cleanServiceGroups applies widget-type specific mappings for commonly used widgets", async () => {
const mod = await import("./service-helpers");
const { cleanServiceGroups } = mod;
const rawGroups = [
{
name: "Core",
services: [
{
name: "svc",
weight: 100,
widgets: [
{ type: "azuredevops", userEmail: "u@example.com", repositoryId: "r" },
{ type: "beszel", version: "2", systemId: "sys" },
{ type: "coinmarketcap", currency: "USD", symbols: "BTC", slugs: "bitcoin", defaultinterval: "1d" },
{ type: "crowdsec", limit24h: "true" },
{ type: "docker", server: "docker-local", container: "c1" },
{ type: "unifi", site: "Home" },
{ type: "proxmox", node: "pve" },
{ type: "proxmoxbackupserver", datastore: "ds" },
{ type: "komodo", showSummary: "true", showStacks: "false" },
{ type: "kubernetes", namespace: "default", app: "app", podSelector: "app=test" },
{
type: "iframe",
src: "https://example.com",
allowFullscreen: true,
allowPolicy: "geolocation",
allowScrolling: false,
classes: "x",
loadingStrategy: "lazy",
referrerPolicy: "no-referrer",
refreshInterval: 1000,
},
{ type: "qbittorrent", enableLeechProgress: "true", enableLeechSize: "true" },
{ type: "opnsense", wan: "wan1" },
{ type: "emby", enableBlocks: "true", enableNowPlaying: "false", enableMediaControl: "true" },
{ type: "tautulli", expandOneStreamToTwoRows: "true", showEpisodeNumber: "true", enableUser: "true" },
{ type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" },
{ type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
{ type: "glances", version: "4", metric: "cpu", refreshInterval: 2000, pointsLimit: 5, diskUnits: "gb" },
{ type: "mjpeg", stream: "s", fit: "contain" },
{ type: "openmediavault", method: "foo.bar" },
{ type: "customapi", mappings: { x: 1 }, display: { y: 2 }, refreshInterval: 5000 },
{
type: "calendar",
integrations: [],
firstDayInWeek: "monday",
view: "agenda",
maxEvents: 10,
previousDays: 2,
showTime: true,
timezone: "UTC",
},
{ type: "dockhand", environment: "prod" },
{ type: "hdhomerun", tuner: 1 },
{ type: "healthchecks", uuid: "u" },
{ type: "speedtest", bitratePrecision: "3", version: "1" },
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
{ type: "wgeasy", threshold: "10", version: "1" },
{ type: "technitium", range: "24h" },
{ type: "lubelogger", vehicleID: "12" },
{ type: "vikunja", enableTaskList: true, version: "1" },
{ type: "prometheusmetric", metrics: [], refreshInterval: 2500 },
{ type: "spoolman", spoolIds: [1, 2] },
{ type: "jellystat", days: "7" },
{ type: "grafana", alerts: [] },
{ type: "unraid", pool1: "a", pool2: "b", pool3: "c", pool4: "d" },
{ type: "yourspotify", interval: "daily" },
],
},
],
groups: [],
},
];
const cleaned = cleanServiceGroups(rawGroups);
const widgets = cleaned[0].services[0].widgets;
expect(widgets.find((w) => w.type === "azuredevops")).toEqual(
expect.objectContaining({ userEmail: "u@example.com", repositoryId: "r" }),
);
expect(widgets.find((w) => w.type === "beszel")).toEqual(expect.objectContaining({ version: 2, systemId: "sys" }));
expect(widgets.find((w) => w.type === "crowdsec")).toEqual(expect.objectContaining({ limit24h: true }));
expect(widgets.find((w) => w.type === "docker")).toEqual(
expect.objectContaining({ server: "docker-local", container: "c1" }),
);
expect(widgets.find((w) => w.type === "komodo")).toEqual(
expect.objectContaining({ showSummary: true, showStacks: false }),
);
expect(widgets.find((w) => w.type === "kubernetes")).toEqual(
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
);
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
});
it("findGroupByName deep-searches and annotates parent", async () => {
const mod = await import("./service-helpers");
const { findGroupByName } = mod;
const groups = [
{
name: "Parent",
groups: [{ name: "Child", services: [], groups: [] }],
services: [],
},
];
const found = findGroupByName(groups, "Child");
expect(found.name).toBe("Child");
expect(found.parent).toBe("Parent");
});
it("getServiceItem prefers configured services over docker/kubernetes", async () => {
// Service present in config -> should return early (no Docker init).
state.servicesYaml = [{ G: [{ S: { icon: "x" } }] }];
const mod = await import("./service-helpers");
const serviceItem = await mod.getServiceItem("G", "S");
expect(serviceItem).toEqual(expect.objectContaining({ name: "S", type: "service", icon: "x" }));
expect(Docker).not.toHaveBeenCalled();
expect(kubeCfg.getKubeConfig).not.toHaveBeenCalled();
});
it("getServiceItem falls back to docker then kubernetes", async () => {
const mod = await import("./service-helpers");
// Miss in config, hit in Docker.
state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }];
state.dockerYaml = { "docker-local": {} };
state.dockerContainers = [
{
Names: ["/c1"],
Labels: {
"homepage.group": "G",
"homepage.name": "S",
},
},
];
expect(await mod.getServiceItem("G", "S")).toEqual(
expect.objectContaining({ name: "S", server: "docker-local", container: "c1" }),
);
// Miss in config, miss in Docker, hit in Kubernetes.
vi.resetModules();
state.servicesYaml = [{ G: [{ Other: { icon: "nope" } }] }];
state.dockerYaml = { "docker-local": {} };
state.dockerContainers = [];
state.kubeConfig = {}; // truthy => proceed
state.kubeServices = [{ name: "S", group: "G", type: "service" }];
kubeApi.listIngress.mockResolvedValueOnce([{}]);
const mod2 = await import("./service-helpers");
expect(await mod2.getServiceItem("G", "S")).toEqual(expect.objectContaining({ name: "S", type: "service" }));
});
it("getServiceItem returns false when the service cannot be found anywhere", async () => {
state.servicesYaml = null;
state.dockerYaml = null;
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.getServiceItem("MissingGroup", "MissingService")).toBe(false);
});
it("getServiceWidget returns false when the widget cannot be found", async () => {
state.servicesYaml = null;
state.dockerYaml = null;
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.default("MissingGroup", "MissingService", 0)).toBe(false);
});
it("getServiceWidget returns widget or widgets[index]", async () => {
state.servicesYaml = [
{
G: [
{
S: { widget: { id: "single" }, widgets: [{ id: "w0" }, { id: "w1" }] },
},
],
},
];
const mod = await import("./service-helpers");
expect(await mod.default("G", "S", -1)).toEqual({ id: "single" });
expect(await mod.default("G", "S", "1")).toEqual({ id: "w1" });
});
it("servicesFromDocker maps homepage labels to groups, filters instance-scoped labels, and parses widget version", async () => {
config.getSettings.mockReturnValue({ instanceName: "foo" });
state.dockerYaml = {
"docker-local": {},
"docker-swarm": { swarm: true },
};
state.dockerContainersByServer["docker-local"] = [
{
Names: ["/c1"],
Labels: {
"homepage.group": "G",
"homepage.name": "Svc",
"homepage.href": "http://svc",
"homepage.widget.version": "3",
"homepage.instance.foo.description": "Desc",
"homepage.instance.bar.description": "Ignore",
},
},
// Missing required labels -> should be skipped with an error.
{
Names: ["/bad"],
Labels: {
"homepage.group": "G",
},
},
];
state.dockerServicesByServer["docker-swarm"] = [
// Swarm service label format.
{
Spec: {
Name: "swarm1",
Labels: {
"homepage.group": "G2",
"homepage.name": "SwarmSvc",
"homepage.widgets[0].version": "2",
},
},
},
];
const mod = await import("./service-helpers");
const discoveredGroups = await mod.servicesFromDocker();
expect(discoveredGroups).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "G",
services: [
expect.objectContaining({
name: "Svc",
server: "docker-local",
container: "c1",
href: "http://svc",
description: "Desc",
widget: { version: 3 },
}),
],
}),
expect.objectContaining({
name: "G2",
services: [
expect.objectContaining({
name: "SwarmSvc",
server: "docker-swarm",
container: "swarm1",
widgets: [{ version: 2 }],
}),
],
}),
]),
);
// The instance.bar.* labels should be ignored when instanceName=foo.
expect(JSON.stringify(discoveredGroups)).not.toContain("Ignore");
expect(state.logger.error).toHaveBeenCalled();
});
it("servicesFromDocker tolerates per-server failures and still returns other results", async () => {
state.dockerYaml = { "docker-a": {}, "docker-b": {} };
Docker.mockImplementationOnce(() => {
throw new Error("boom");
});
state.dockerContainers = [{ Names: ["/c1"], Labels: { "homepage.group": "G", "homepage.name": "Svc" } }];
const mod = await import("./service-helpers");
const discoveredGroups = await mod.servicesFromDocker();
expect(discoveredGroups).toEqual([
{ name: "G", services: [expect.objectContaining({ name: "Svc", container: "c1" })] },
]);
expect(["docker-a", "docker-b"]).toContain(discoveredGroups[0].services[0].server);
expect(state.logger.error).toHaveBeenCalled();
});
it("servicesFromKubernetes returns [] when kubernetes is not configured", async () => {
state.kubeConfig = null;
const mod = await import("./service-helpers");
expect(await mod.servicesFromKubernetes()).toEqual([]);
});
it("servicesFromKubernetes maps discoverable resources into service groups", async () => {
config.getSettings.mockReturnValue({ instanceName: "foo" });
state.kubeConfig = {}; // truthy
kubeApi.listIngress.mockResolvedValueOnce([{ kind: "Ingress" }]);
kubeApi.isDiscoverable.mockReturnValueOnce(true);
state.kubeServices = [{ name: "S", group: "G", type: "service", href: "http://k" }];
const mod = await import("./service-helpers");
const groups = await mod.servicesFromKubernetes();
expect(groups).toEqual([
{
name: "G",
services: [{ name: "S", type: "service", href: "http://k" }],
},
]);
expect(kubeApi.isDiscoverable).toHaveBeenCalledWith({ kind: "Ingress" }, "foo");
});
it("servicesFromKubernetes logs and rethrows unexpected errors", async () => {
state.kubeConfig = {}; // truthy
kubeApi.listIngress.mockRejectedValueOnce(new Error("boom"));
const mod = await import("./service-helpers");
await expect(mod.servicesFromKubernetes()).rejects.toThrow("boom");
expect(state.logger.error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { get, set } from "./shvl";
describe("utils/config/shvl", () => {
it("get reads nested paths with arrays and returns default when missing", () => {
const obj = { a: { b: [{ c: 1 }] } };
expect(get(obj, "a.b[0].c")).toBe(1);
expect(get(obj, "a.b[1].c", "dflt")).toBe("dflt");
});
it("set creates nested objects/arrays as needed", () => {
const obj = {};
set(obj, "a.b[0].c", 123);
expect(obj).toEqual({ a: { b: [{ c: 123 }] } });
});
it("set blocks prototype pollution", () => {
const obj = {};
set(obj, "__proto__.polluted", true);
set(obj, "a.__proto__.polluted", true);
set(obj, "constructor.prototype.polluted", true);
expect(obj.polluted).toBeUndefined();
expect({}.polluted).toBeUndefined();
expect(Object.prototype.polluted).toBeUndefined();
});
});

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fs, yaml, config } = vi.hoisted(() => ({
fs: {
readFile: vi.fn(),
},
yaml: {
load: vi.fn(),
},
config: {
CONF_DIR: "/conf",
substituteEnvironmentVars: vi.fn((s) => s),
default: vi.fn(),
},
}));
vi.mock("fs", () => ({
promises: fs,
}));
vi.mock("js-yaml", () => ({
default: yaml,
...yaml,
}));
vi.mock("utils/config/config", () => config);
import { cleanWidgetGroups, getPrivateWidgetOptions, widgetsFromConfig } from "./widget-helpers";
describe("utils/config/widget-helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("widgetsFromConfig maps YAML into a typed widgets array with indices", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { provider: "google", url: "http://x", key: "k" } }]);
const widgets = await widgetsFromConfig();
expect(widgets).toEqual([
{
type: "search",
options: { index: 0, provider: "google", url: "http://x", key: "k" },
},
]);
});
it("cleanWidgetGroups removes private options and hides url except for search/glances", async () => {
const cleaned = await cleanWidgetGroups([
{ type: "search", options: { index: 0, url: "http://x", username: "u", password: "p" } },
{ type: "something", options: { index: 1, url: "http://y", key: "k", foo: 1 } },
{ type: "glances", options: { index: 2, url: "http://z", apiKey: "k", bar: 2 } },
]);
expect(cleaned[0].options.url).toBe("http://x");
expect(cleaned[0].options.username).toBeUndefined();
expect(cleaned[1].options.url).toBeUndefined();
expect(cleaned[1].options.key).toBeUndefined();
expect(cleaned[1].options.foo).toBe(1);
expect(cleaned[2].options.url).toBe("http://z");
expect(cleaned[2].options.apiKey).toBeUndefined();
});
it("getPrivateWidgetOptions returns private options for a specific widget", async () => {
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u", password: "p", key: "k" } }]);
const options = await getPrivateWidgetOptions("search", 0);
expect(options).toEqual(
expect.objectContaining({
index: 0,
url: "http://x",
username: "u",
password: "p",
key: "k",
}),
);
// And the full list when no args are provided
fs.readFile.mockResolvedValueOnce("ignored");
yaml.load.mockReturnValueOnce([{ search: { url: "http://x", username: "u" } }]);
const all = await getPrivateWidgetOptions();
expect(Array.isArray(all)).toBe(true);
expect(all[0].options.url).toBe("http://x");
});
});