Files
dozzle/assets/components/LogViewer/AlertLogItem.spec.ts
T

155 lines
6.4 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import AlertLogItem from "./AlertLogItem.vue";
import { AlertLogEntry } from "@/models/LogEntry";
import type { CloudAlert } from "@/composable/cloudAlerts";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
const ns = (n: number) => n * 1_000_000;
function mountAlert(overrides: Partial<CloudAlert> = {}) {
const alert: CloudAlert = {
alertId: "6teOh3RH",
containerId: "abc",
hostId: "h",
ts: ns(1_000_000),
headline: "Pool exhausted",
level: "error",
eventCount: 47,
createdAt: ns(1_000_000),
isOrigin: true,
...overrides,
};
return mount(AlertLogItem, {
props: { logEntry: new AlertLogEntry(alert, new Date(alert.ts / 1_000_000)) },
global: {
stubs: { LogItem: { template: "<div><slot /></div>" } },
mocks: { $t: (key: string, arg?: unknown) => `${key}:${JSON.stringify(arg)}` },
},
});
}
describe("<AlertLogItem />", () => {
test("renders the headline and event count", () => {
const wrapper = mountAlert();
expect(wrapper.text()).toContain("Pool exhausted");
expect(wrapper.text()).toContain("label.alert-events");
expect(wrapper.find(".alert-row").exists()).toBe(true);
});
// The row must never carry data-level: LogLevel.vue ships an unscoped
// `[data-level="error"] { @apply !bg-red }`, so reusing that attribute name
// fills the row solid red with !important and no local rule can win. This is
// the regression guard for that — the styling itself is not testable here,
// but the attribute that triggers it is.
test("does not carry the globally-styled data-level attribute", () => {
const wrapper = mountAlert({ level: "error" });
expect(wrapper.find("[data-level]").exists()).toBe(false);
expect(wrapper.find("[data-alert-level='error']").exists()).toBe(true);
});
// The whole point of the one-line treatment: detail costs an interaction, so
// an alert never takes more than a row of the log stream uninvited.
describe("details", () => {
test("keeps the investigation collapsed until asked", async () => {
const wrapper = mountAlert({ investigation: "pool exhausted under retry storm" });
expect(wrapper.text()).not.toContain("retry storm");
await wrapper.get("button").trigger("click");
expect(wrapper.text()).toContain("retry storm");
});
test("collapses again on a second press", async () => {
const wrapper = mountAlert({ investigation: "pool exhausted under retry storm" });
await wrapper.get("button").trigger("click");
await wrapper.get("button").trigger("click");
expect(wrapper.text()).not.toContain("retry storm");
});
// A control that opens an empty panel is worse than no control.
test("offers no button when there is nothing to expand", () => {
expect(mountAlert().find("button").exists()).toBe(false);
});
test("offers the button for held-back counts and multi-container incidents", () => {
expect(mountAlert({ suppressedCount: 34 }).find("button").exists()).toBe(true);
expect(mountAlert({ containerCount: 3 }).find("button").exists()).toBe(true);
});
// summary is the description the Cloud alert page shows, and is usually all
// a non-triaged alert has to offer.
test("reveals the summary, not just the investigation", async () => {
const wrapper = mountAlert({ summary: "Container running with unknown health" });
expect(wrapper.text()).not.toContain("unknown health");
await wrapper.get("button").trigger("click");
expect(wrapper.text()).toContain("unknown health");
});
});
describe("incident extent", () => {
test("reports how long the incident kept firing, inline on the summary", () => {
expect(mountAlert({ ts: ns(0), lastActivityAt: ns(40 * 60_000) }).text()).toMatch(/40/);
});
// Cloud's aggregation window is 15s, so a sub-minute span is an artefact of
// batching rather than a real incident length.
test("stays silent for a sub-minute span", () => {
expect(mountAlert({ ts: ns(0), lastActivityAt: ns(20_000) }).text()).not.toMatch(/\d+\s*(s|sec)/);
});
test("stays silent when there is no follow-up activity at all", () => {
// Only the event count on the summary — no trailing duration clause.
expect(mountAlert({ ts: ns(0), lastActivityAt: undefined }).text()).not.toMatch(/,\s*\d/);
});
});
// alerts.level is free text and defaults to '' for anything that never went
// through a summarizer. A neutral fallback made the common case look like an
// ordinary log line, which is the one thing this row must not look like.
describe("level styling", () => {
test("treats an empty level as an error", () => {
expect(mountAlert({ level: "" }).find("[data-alert-level='error']").exists()).toBe(true);
});
test("treats an unrecognised level as an error", () => {
expect(mountAlert({ level: "mystery" }).find("[data-alert-level='error']").exists()).toBe(true);
});
test("keeps warn and info distinct", () => {
expect(mountAlert({ level: "warning" }).find("[data-alert-level='warn']").exists()).toBe(true);
expect(mountAlert({ level: "debug" }).find("[data-alert-level='info']").exists()).toBe(true);
});
});
test("shows the container count only when the incident is wider than one", async () => {
expect(mountAlert({ containerCount: 1 }).text()).not.toContain("label.alert-containers");
const wide = mountAlert({ containerCount: 3 });
await wide.get("button").trigger("click");
expect(wide.text()).toContain("label.alert-containers");
});
test("shows held-back count only when triage suppressed follow-ups", async () => {
expect(mountAlert({ suppressedCount: 0 }).text()).not.toContain("label.alert-held-back");
const held = mountAlert({ suppressedCount: 34 });
await held.get("button").trigger("click");
expect(held.text()).toContain("label.alert-held-back");
});
test("links out to cloud only when the key has an app url", () => {
expect(mountAlert({ url: undefined }).find("a").exists()).toBe(false);
expect(mountAlert({ url: "https://app.example.com/alerts/1" }).find("a").attributes("href")).toBe(
"https://app.example.com/alerts/1",
);
});
});