Files
dozzle/assets/components/WelcomeModal.spec.ts
T
2026-05-19 15:46:54 +00:00

164 lines
5.8 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import WelcomeModal from "./WelcomeModal.vue";
vi.mock("vue-router");
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "" },
withBase: (path: string) => path,
}));
const i18n = createI18n({
legacy: false,
locale: "en",
fallbackLocale: "en",
missingWarn: false,
fallbackWarn: false,
messages: {
en: {
cloud: {
welcome: {
"create-alerts": "Turn on selected signals",
signals: {
exited: "Container exited with an error",
"exited-desc": "Fires when a container stops with a non-zero exit code.",
unhealthy: "Container became unhealthy",
"unhealthy-desc": "Fires when a container's healthcheck transitions to unhealthy.",
oom: "Container was killed by the kernel (OOM)",
"oom-desc": "Fires when Docker reports an out-of-memory kill.",
restart: "Container restarted",
"restart-desc": "Off by default — noisy on its own; Cloud also uses this for loop detection.",
disk: "Disk space running low on any volume",
"disk-desc": "Fires when any mounted volume is over 85% full.",
},
},
},
},
},
});
function mountModal() {
return mount(WelcomeModal, {
global: {
plugins: [i18n],
},
});
}
describe("<WelcomeModal /> Create First Alert", () => {
const pushSpy = vi.fn();
beforeEach(() => {
// jsdom's HTMLDialogElement lacks .close()/.showModal() — stub them so WelcomeModal's close() works.
if (!HTMLDialogElement.prototype.close) {
HTMLDialogElement.prototype.close = function () {};
}
if (!HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function () {};
}
vi.mocked(useRouter).mockReturnValue({
push: pushSpy,
} as unknown as ReturnType<typeof useRouter>);
pushSpy.mockReset();
vi.restoreAllMocks();
});
async function openAndAdvance(wrapper: ReturnType<typeof mountModal>) {
// open() seeds defaultOn signals
(wrapper.vm as unknown as { open: () => void }).open();
const vm = wrapper.vm as unknown as { step: "step1" | "step2" };
vm.step = "step2";
await wrapper.vm.$nextTick();
}
test("POSTs one rule per checked default signal and routes to /notifications", async () => {
const fetchMock = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => {
const u = String(url);
if (u.includes("/api/notifications/dispatchers")) {
return new Response(JSON.stringify([{ id: 7, type: "cloud", name: "Dozzle Cloud" }]), { status: 200 });
}
if (u.includes("/api/notifications/rules")) {
return new Response(JSON.stringify({ id: 42 }), { status: 200 });
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const wrapper = mountModal();
await openAndAdvance(wrapper);
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
expect(cta).toBeDefined();
await cta!.trigger("click");
await flushPromises();
const ruleCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes("/api/notifications/rules"));
expect(ruleCalls).toHaveLength(4); // exited + unhealthy + oom + disk on by default; restart off
const bodies = ruleCalls.map((c) => JSON.parse((c[1] as RequestInit).body as string));
const eventExpressions = bodies.map((b) => b.eventExpression).filter(Boolean);
expect(eventExpressions).toContain('name == "die" && attributes["exitCode"] != "0"');
expect(eventExpressions).toContain('name == "health_status" && attributes["healthStatus"] == "unhealthy"');
expect(eventExpressions).toContain('name == "oom"');
expect(eventExpressions).not.toContain('name == "restart"');
const metricExpressions = bodies.map((b) => b.metricExpression).filter(Boolean);
expect(metricExpressions).toContain("any(mounts, .usedPercent >= 85)");
// disk rule should carry its own cooldown/sampleWindow; event rules should remain at 0
const diskBody = bodies.find((b) => b.metricExpression === "any(mounts, .usedPercent >= 85)");
expect(diskBody).toMatchObject({
enabled: true,
dispatcherId: 7,
cooldown: 3600,
sampleWindow: 60,
containerExpression: "true",
eventExpression: "",
});
// event-based POSTs use cloud dispatcher id with no cooldown
for (const b of bodies.filter((x) => x.eventExpression)) {
expect(b).toMatchObject({
enabled: true,
dispatcherId: 7,
cooldown: 0,
sampleWindow: 0,
containerExpression: "true",
metricExpression: "",
});
}
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications" });
});
test("falls back to ?action=create-alert when POST fails", async () => {
const fetchMock = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => {
const u = String(url);
if (u.includes("/api/notifications/dispatchers")) {
return new Response(JSON.stringify([{ id: 7, type: "cloud", name: "Dozzle Cloud" }]), { status: 200 });
}
if (u.includes("/api/notifications/rules")) {
return new Response("{}", { status: 500 });
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const wrapper = mountModal();
await openAndAdvance(wrapper);
const cta = wrapper.findAll("button").find((b) => b.text().toLowerCase().includes("turn on"));
await cta!.trigger("click");
await flushPromises();
expect(pushSpy).toHaveBeenCalledWith({ path: "/notifications", query: { action: "create-alert" } });
});
});