Files
openobserve/web/src/components/alertLibrary/LibraryCard.spec.ts
T
70a89a7c34 feat(license): gate features by license in the UI (#16233)
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
2026-11-04 10:12:58 +00:00

474 lines
22 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import LibraryCard from "./LibraryCard.vue";
import i18n from "@/locales";
import type { AlertLibraryEntry } from "@/types/alertLibrary";
const entry: AlertLibraryEntry = {
id: "k8s/pod-oom-killed",
name: "pod-oom-killed",
pack: "k8s",
category: "pod",
title: "Pod OOM Killed",
severity: "critical",
description: "A container in the pod was terminated by the kernel OOM killer.",
stream: "kube_pod_container_status_last_terminated_reason",
stream_type: "metrics",
query_type: "promql",
required_streams: ["kube_pod_container_status_last_terminated_reason"],
path: "packs/k8s/alerts/pod/pod-oom-killed.json",
content_hash: "abc123",
};
const mountCard = (props: Record<string, unknown> = {}) =>
mount(LibraryCard, {
props: { entry, ready: true, ...props },
global: { plugins: [i18n] },
});
describe("LibraryCard", () => {
it("shows the title, the description and the stream it reads", () => {
const text = mountCard().text();
expect(text).toContain("Pod OOM Killed");
expect(text).toContain("terminated by the kernel OOM killer");
expect(text).toContain("kube_pod_container_status_last_terminated_reason");
});
it("labels the severity", () => {
expect(mountCard().find('[data-test="alert-library-card-severity"]').text()).toBe("Critical");
});
it("shows warning severity in the badge group's amber, matching its P3 install", () => {
const wrapper = mountCard({ entry: { ...entry, severity: "warning" } });
const badge = wrapper.find('[data-test="alert-library-card-severity"]');
expect(badge.text()).toBe("Warning");
expect(badge.html()).toContain("amber");
});
it("shows the query language as a quiet neutral chip", () => {
expect(mountCard().find('[data-test="alert-library-card-query-type"]').text()).toBe("PromQL");
});
it("carries NO ready label — it would describe almost every card and signal nothing", () => {
expect(mountCard().find('[data-test="alert-library-card-needs-data"]').exists()).toBe(false);
expect(mountCard().text()).not.toContain("Ready");
});
it("names the missing stream, so the card says WHY it differs", () => {
// The whole point of the treatment: a reader must not have to infer the
// cause from a border style. The stream that is absent is stated on the
// card, and the tooltip explains the consequence.
const wrapper = mountCard({ ready: false });
const note = wrapper.find('[data-test="alert-library-card-needs-data"]');
expect(note.exists()).toBe(true);
expect(note.text()).toBe("Not ingested");
expect(note.attributes("title")).toContain("kube_pod_container_status_last_terminated_reason");
expect(note.attributes("title")).toContain("would never fire");
});
it("marks the state in a tone that each theme can wear", () => {
// Warning and not error: an alert with no data is not broken, it is waiting.
//
// The chip uses warning-QUIET, whose tokens differ by theme, because 959 of
// 1242 alerts are not ingested on a typical org. Light wears the amber wash
// at a dozen per screen; dark, where the same ramp is full-chroma against
// near-black, turns the chip neutral and lets the icon carry the colour.
const wrapper = mountCard({ ready: false });
const note = wrapper.find('[data-test="alert-library-card-needs-data"]');
const mark = wrapper.find('[data-test="alert-library-card-needs-data-mark"]');
expect(note.classes().join(" ")).toMatch(/badge-warning-quiet/);
// Never the filled variant: that one is identical in both themes.
expect(note.classes().join(" ")).not.toMatch(/badge-warning-soft-bg/);
expect(note.html()).not.toMatch(/badge-error/);
// The icon stays amber in BOTH themes, so dark keeps a colour cue.
expect(mark.classes().join(" ")).toMatch(/badge-warning/);
});
it("shapes the note like the query-language chip beside it", () => {
// The user asked for the two to read as the same kind of thing — one chip
// says what language it speaks, the other says what it is missing — so the
// note must not drift into its own bespoke treatment.
const wrapper = mountCard({ ready: false });
const shapeOf = (test: string) =>
wrapper
.find(`[data-test="${test}"]`)
.classes()
.filter((c) => c.startsWith("rounded-") || c.startsWith("px-") || c.startsWith("text-3xs"))
.sort();
const shape = shapeOf("alert-library-card-needs-data");
expect(shape.length).toBeGreaterThan(0); // else the comparison is vacuous
expect(shape).toEqual(shapeOf("alert-library-card-query-type"));
});
it("drops the dashed border — it only meant anything next to a solid one", () => {
// The border was the original complaint: dashed vs solid is invisible until
// you happen to see both, so a pack where every stream is missing looked
// arbitrarily different from one where none were. The chip carries it now.
const dashed = (ready: boolean) =>
mountCard({ ready })
.find('[data-test="alert-library-card-k8s/pod-oom-killed"]')
.classes()
.join(" ");
expect(dashed(false)).not.toMatch(/border-dashed/);
expect(dashed(false)).toBe(dashed(true));
});
it("puts the note at the end of the stream line, beside the stream it names", () => {
const wrapper = mountCard({ ready: false });
const line = wrapper.find('[data-test="alert-library-card-stream"]');
expect(line.find('[data-test="alert-library-card-needs-data"]').exists()).toBe(true);
expect(line.text()).toContain("kube_pod_container_status_last_terminated_reason");
expect(line.element.lastElementChild?.getAttribute("data-test")).toBe(
"alert-library-card-needs-data",
);
});
it("shows the stream on its own line with a database icon, under the description", () => {
const wrapper = mountCard();
const line = wrapper.find('[data-test="alert-library-card-stream"]');
expect(line.exists()).toBe(true);
expect(line.text()).toBe("kube_pod_container_status_last_terminated_reason");
expect(line.findComponent({ name: "OIcon" }).props("name")).toBe("database");
});
it("keeps the stream name out of the chip row", () => {
const tag = mountCard().find('[data-test="alert-library-card-query-type"]').element;
expect(tag.parentElement?.textContent).not.toContain(
"kube_pod_container_status_last_terminated_reason",
);
});
it("renders no stream line for an entry without a stream", () => {
const wrapper = mountCard({ entry: { ...entry, stream: "" } });
expect(wrapper.find('[data-test="alert-library-card-stream"]').exists()).toBe(false);
});
it("still flags a not-ingested entry that has no stream", () => {
const wrapper = mountCard({ entry: { ...entry, stream: "" }, ready: false });
const tag = wrapper.find('[data-test="alert-library-card-needs-data"]');
expect(tag.exists()).toBe(true);
expect(tag.attributes("title")).toBeUndefined();
});
it("does not dim the card, so its description stays readable", () => {
// The old opacity-65 faded the description people need in order to judge
// the alert, and overstated the state — an unavailable alert can still be
// read, previewed and installed.
const root = mountCard({ ready: false }).find(
'[data-test="alert-library-card-k8s/pod-oom-killed"]',
);
expect(root.classes().join(" ")).not.toMatch(/opacity-/);
});
it("shows the stream as plain provenance when it is available", () => {
const wrapper = mountCard({ ready: true });
expect(wrapper.find('[data-test="alert-library-card-needs-data"]').exists()).toBe(false);
expect(wrapper.text()).toContain("kube_pod_container_status_last_terminated_reason");
});
it("keeps a stable data-test keyed on the library id", () => {
expect(mountCard().find('[data-test="alert-library-card-k8s/pod-oom-killed"]').exists()).toBe(
true,
);
});
it("opens the drawer when the card body is clicked", () => {
// Kept as a mouse convenience once the card stopped being a button itself:
// everything it offers is also reachable from a real control inside it.
const wrapper = mountCard();
wrapper.find('[data-test="alert-library-card-k8s/pod-oom-killed"]').trigger("click");
expect(wrapper.emitted("open")).toHaveLength(1);
});
it("opens the drawer from the title button — once, not twice", () => {
// The title sits inside the card's own click handler. Without stopping
// propagation a single click opens the drawer twice.
const wrapper = mountCard();
wrapper.find('[data-test="alert-library-card-title-k8s/pod-oom-killed"]').trigger("click");
expect(wrapper.emitted("open")).toHaveLength(1);
});
it("makes the TITLE the control, so a checkbox can live beside it", () => {
// The card used to be the affordance. A card with two actions cannot be:
// a control nested inside role="button" is invalid for keyboard and AT.
const wrapper = mountCard();
const root = wrapper.find('[data-test="alert-library-card-k8s/pod-oom-killed"]');
expect(root.attributes("role")).toBeUndefined();
expect(root.attributes("tabindex")).toBeUndefined();
expect(root.attributes("aria-label")).toBeUndefined();
const title = wrapper.find('[data-test="alert-library-card-title-k8s/pod-oom-killed"]');
expect(title.element.tagName).toBe("BUTTON");
expect(title.text()).toContain("Pod OOM Killed");
});
it("keeps the pointer cursor on the card, which is still clickable", () => {
const root = mountCard().find('[data-test="alert-library-card-k8s/pod-oom-killed"]');
expect(root.classes()).toContain("cursor-pointer");
});
it("moves the focus ring to the title, where focus can actually land", () => {
const wrapper = mountCard();
const ringed = (test: string) =>
wrapper
.find(`[data-test="${test}"]`)
.classes()
.some((c) => c.startsWith("focus-visible:ring"));
expect(ringed("alert-library-card-title-k8s/pod-oom-killed")).toBe(true);
expect(ringed("alert-library-card-k8s/pod-oom-killed")).toBe(false);
});
// ── selection ────────────────────────────────────────────────────────────
it("renders a selection checkbox, always — not on hover", () => {
// Hover-reveal leaves a touch user with no way to START a selection from a
// card, and a hidden control cannot take the focus that would reveal it.
// Both mechanisms §7.2a rejects are ruled out: display and opacity.
const wrapper = mountCard();
const box = wrapper.find('[data-test="alert-library-select-k8s/pod-oom-killed"]');
expect(box.exists()).toBe(true);
expect(box.attributes("hidden")).toBeUndefined();
// Read off the CARD, not the checkbox's own root: the reveal would live on
// a wrapper the checkbox knows nothing about.
const html = wrapper.html();
expect(html).not.toMatch(/opacity-0/);
expect(html).not.toMatch(/group-hover:/);
});
it("no longer opens the drawer from the card's own key handlers", () => {
// The root is not a control any more. A leftover handler there would put an
// action on a non-interactive element, which nothing in web/ lints for.
const wrapper = mountCard();
const root = wrapper.find('[data-test="alert-library-card-k8s/pod-oom-killed"]');
root.trigger("keydown.enter");
root.trigger("keydown.space");
expect(wrapper.emitted("open")).toBeUndefined();
});
it("opens from the title on Enter and Space, because it is a real button", () => {
// Native button semantics: asserted through the element, not a handler, so
// this stays true however the click is wired.
const title = mountCard().find('[data-test="alert-library-card-title-k8s/pod-oom-killed"]');
expect(title.element.tagName).toBe("BUTTON");
expect((title.element as HTMLButtonElement).type).toBe("button");
expect(title.attributes("disabled")).toBeUndefined();
});
it("puts the checkbox before the title, so tab order reads left to right", () => {
const wrapper = mountCard();
const stops = wrapper
.findAll('[data-test^="alert-library-card-title-"], [role="checkbox"]')
.map((node) => (node.attributes("role") === "checkbox" ? "checkbox" : "title"));
expect(stops).toEqual(["checkbox", "title"]);
});
it("names the checkbox, which has no visible label of its own", () => {
const wrapper = mountCard();
const box = wrapper.find('[data-test="alert-library-select-k8s/pod-oom-killed"]');
expect(box.find('[role="checkbox"]').attributes("aria-label")).toContain("Pod OOM Killed");
});
it("emits update:selected from the checkbox and does NOT open the drawer", () => {
const wrapper = mountCard();
wrapper
.find('[data-test="alert-library-select-k8s/pod-oom-killed"]')
.find('[role="checkbox"]')
.trigger("click");
expect(wrapper.emitted("update:selected")).toEqual([[true]]);
expect(wrapper.emitted("open")).toBeUndefined();
});
it("unticks from the checkbox when it is already selected", () => {
const wrapper = mountCard({ selected: true });
wrapper
.find('[data-test="alert-library-select-k8s/pod-oom-killed"]')
.find('[role="checkbox"]')
.trigger("click");
expect(wrapper.emitted("update:selected")).toEqual([[false]]);
});
it("marks a selected card, so the ring is not the only cue", () => {
const test = '[data-test="alert-library-card-k8s/pod-oom-killed"]';
expect(mountCard({ selected: true }).find(test).attributes("data-selected")).toBe("true");
expect(mountCard().find(test).attributes("data-selected")).toBe("false");
});
it("drops the default border rules when selected, rather than layering over them", () => {
// `hover:border-border-strong` outranks a plain `border-accent`, so leaving
// the resting rules in place makes a selected card lose its accent on hover.
const classes = (selected: boolean) =>
mountCard({ selected }).find('[data-test="alert-library-card-k8s/pod-oom-killed"]').classes();
expect(classes(true)).toContain("border-accent");
expect(classes(true)).not.toContain("border-border-default");
expect(classes(true)).not.toContain("hover:border-border-strong");
expect(classes(false)).toContain("border-border-default");
expect(classes(false)).toContain("hover:border-border-strong");
});
it("names the title button by its own text, so the heading is not renamed", () => {
// An aria-label on the only child of the <h3> becomes the HEADING's name,
// so screen-reader heading navigation reads "Open Pod OOM Killed".
const title = mountCard().find('[data-test="alert-library-card-title-k8s/pod-oom-killed"]');
expect(title.attributes("aria-label")).toBeUndefined();
expect(title.text()).toBe("Pod OOM Killed");
});
it("defaults to unselected, so the gallery owns the state", () => {
const box = mountCard().find('[data-test="alert-library-select-k8s/pod-oom-killed"]');
expect(box.find('[role="checkbox"]').attributes("aria-checked")).toBe("false");
});
it("survives an entry with no description", () => {
const wrapper = mountCard({ entry: { ...entry, description: "" } });
expect(wrapper.text()).toContain("Pod OOM Killed");
});
describe("anomaly alerts", () => {
const anomaly: AlertLibraryEntry = {
...entry,
id: "apm/traces_server_error_ratio_anomaly",
query_type: "sql",
alert_type: "anomaly_detection",
};
it("badges an anomaly alert", () => {
const badge = mountCard({ entry: anomaly }).find('[data-test="alert-library-card-anomaly"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe("Anomaly");
});
it("does not badge a scheduled alert, nor one from a manifest without alert_type", () => {
expect(mountCard().find('[data-test="alert-library-card-anomaly"]').exists()).toBe(false);
const scheduled = mountCard({ entry: { ...entry, alert_type: "scheduled" } });
expect(scheduled.find('[data-test="alert-library-card-anomaly"]').exists()).toBe(false);
});
it("disables the card, its checkbox and its title button when the license locks it", async () => {
const wrapper = mountCard({ entry: anomaly, locked: true, licenseLockedReason: "Locked" });
const card = wrapper.find(`[data-test="alert-library-card-${anomaly.id}"]`);
expect(card.attributes("aria-disabled")).toBe("true");
expect(card.classes()).toContain("cursor-not-allowed");
expect(
wrapper.find(`[data-test="alert-library-card-title-${anomaly.id}"]`).attributes("disabled"),
).toBeDefined();
await card.trigger("click");
expect(wrapper.emitted("open")).toBeUndefined();
});
it("shows the Enterprise badge only when locked", () => {
const locked = mountCard({ entry: anomaly, locked: true });
expect(locked.find('[data-test="alert-library-card-enterprise"]').text()).toBe("Enterprise");
const open = mountCard({ entry: anomaly });
expect(open.find('[data-test="alert-library-card-enterprise"]').exists()).toBe(false);
});
});
describe("actions", () => {
const ADD_ID = `alert-library-card-add-${entry.id}`;
const CUSTOMIZE_ID = `alert-library-card-customize-${entry.id}`;
const ADD = `[data-test="${ADD_ID}"]`;
const CUSTOMIZE = `[data-test="${CUSTOMIZE_ID}"]`;
const anomaly: AlertLibraryEntry = {
...entry,
query_type: "sql",
alert_type: "anomaly_detection",
};
it("offers a short Add label and an icon-only Customize on a scheduled card", () => {
const wrapper = mountCard();
expect(wrapper.find(ADD).text()).toBe("Add");
const customize = wrapper.find(CUSTOMIZE);
expect(customize.text()).toBe("");
expect(customize.attributes("aria-label")).toBe("Customize in editor");
const button = wrapper
.findAllComponents({ name: "OButton" })
.find((b) => b.attributes("data-test") === CUSTOMIZE_ID);
expect(button?.props("variant")).toBe("outline");
expect(button?.props("size")).toBe("icon-sm");
expect(button?.props("iconLeft")).toBe("edit");
});
it("explains Customize in a tooltip and spells out Add on hover", () => {
const wrapper = mountCard();
expect(wrapper.find(ADD).attributes("title")).toBe("Add to alerts");
const tip = wrapper.find(CUSTOMIZE).findComponent({ name: "OTooltip" });
expect(tip.props("content")).toBe("Opens the alert editor prefilled");
});
it("puts Customize before Add, at the end of the chip row", () => {
const wrapper = mountCard();
const row = wrapper.find('[data-test="alert-library-card-query-type"]').element.parentElement;
const ids = Array.from(row?.querySelectorAll("[data-test]") ?? []).map((n) =>
n.getAttribute("data-test"),
);
expect(ids.indexOf(CUSTOMIZE_ID)).toBeLessThan(ids.indexOf(ADD_ID));
expect(ids.indexOf(CUSTOMIZE_ID)).toBeGreaterThan(-1);
});
it("offers Customize on a SQL card as well as a PromQL one", () => {
const wrapper = mountCard({ entry: { ...entry, query_type: "sql" } });
expect(wrapper.find(CUSTOMIZE).exists()).toBe(true);
});
it("emits install from Add without opening the card", async () => {
const wrapper = mountCard();
await wrapper.find(ADD).trigger("click");
expect(wrapper.emitted("install")).toHaveLength(1);
expect(wrapper.emitted("open")).toBeUndefined();
});
it("emits customize from Customize without opening the card", async () => {
const wrapper = mountCard();
await wrapper.find(CUSTOMIZE).trigger("click");
expect(wrapper.emitted("customize")).toHaveLength(1);
expect(wrapper.emitted("open")).toBeUndefined();
});
it("offers Add but not Customize on an anomaly card, which has no editor prefill", () => {
const wrapper = mountCard({ entry: anomaly });
expect(wrapper.find(ADD).exists()).toBe(true);
expect(wrapper.find(CUSTOMIZE).exists()).toBe(false);
});
it("ties both buttons to their own card's title, since every card repeats the labels", () => {
const wrapper = mountCard();
const title = wrapper.find(`[data-test="alert-library-card-title-${entry.id}"]`);
const titleId = title.attributes("id");
expect(titleId).toBeTruthy();
expect(title.text()).toBe("Pod OOM Killed");
expect(wrapper.find(ADD).attributes("aria-describedby")).toBe(titleId);
expect(wrapper.find(CUSTOMIZE).attributes("aria-describedby")).toBe(titleId);
const other = mountCard({ entry: { ...entry, id: "k8s/node-disk-pressure" } });
const otherTitle = other.find(
'[data-test="alert-library-card-title-k8s/node-disk-pressure"]',
);
expect(otherTitle.attributes("id")).not.toBe(titleId);
});
it("offers neither on a locked card, whose click is the upsell", () => {
const wrapper = mountCard({ entry: anomaly, locked: true });
expect(wrapper.find(ADD).exists()).toBe(false);
expect(wrapper.find(CUSTOMIZE).exists()).toBe(false);
});
});
});