mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-06 02:01:22 -07:00
Chore: homepage tests (#6278)
This commit is contained in:
@@ -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) {
|
||||
|
||||
265
src/utils/config/api-response.test.js
Normal file
265
src/utils/config/api-response.test.js
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
90
src/utils/config/config.check-copy.test.js
Normal file
90
src/utils/config/config.check-copy.test.js
Normal 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" }));
|
||||
});
|
||||
});
|
||||
59
src/utils/config/config.test.js
Normal file
59
src/utils/config/config.test.js
Normal 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" } });
|
||||
});
|
||||
});
|
||||
@@ -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]) {
|
||||
|
||||
109
src/utils/config/docker.test.js
Normal file
109
src/utils/config/docker.test.js
Normal 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" });
|
||||
});
|
||||
});
|
||||
108
src/utils/config/kubernetes.test.js
Normal file
108
src/utils/config/kubernetes.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
41
src/utils/config/proxmox.test.js
Normal file
41
src/utils/config/proxmox.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
587
src/utils/config/service-helpers.test.js
Normal file
587
src/utils/config/service-helpers.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
30
src/utils/config/shvl.test.js
Normal file
30
src/utils/config/shvl.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
88
src/utils/config/widget-helpers.test.js
Normal file
88
src/utils/config/widget-helpers.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user