Files
openobserve/web/src/components/ai-assistant/shell/AssistantHeaderButton.spec.ts
T

488 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { gateLearnOpen } from "@/components/ai-assistant/gate/AssistantGateState.vue";
import type { AssistantCapability, GateCode } from "@/composables/assistant";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { raw } from "@/types/i18n";
import { fakeAssistant, fakeRouter, fakeVuex } from "../__tests__/fakeAssistant";
import { fakeRuns, runRow } from "../runs/runsTestKit";
import AssistantHeaderButton from "./AssistantHeaderButton.vue";
import { closeLockedPanel, useLockedPanel } from "./lockedPanel";
const { fake, openAssistant, track, toast, lg, otherRuns } = vi.hoisted(() => ({
lg: { value: true },
fake: { current: null as any },
otherRuns: { current: null as any },
openAssistant: vi.fn(async () => ({ ok: true })),
track: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/composables/useBreakpoint", async () => {
const { computed } = await import("vue");
return {
default: () => ({
lgUp: computed(() => lg.value),
mdUp: computed(() => lg.value),
isMobile: computed(() => !lg.value),
isTablet: computed(() => false),
isDesktop: computed(() => lg.value),
}),
};
});
vi.mock("@/composables/assistant", async (importOriginal) => ({
...(await importOriginal<typeof import("@/composables/assistant")>()),
useAssistantStore: () => fake.current.store,
useAssistantCapability: () => fake.current.handle,
useAssistantHistory: () => fake.current.history,
useAssistantRuns: () => otherRuns.current,
openAssistant,
}));
vi.mock("@/services/assistantAnalytics", async (importOriginal) => ({
...(await importOriginal<typeof import("@/services/assistantAnalytics")>()),
trackAssistant: track,
}));
vi.mock("@/lib/feedback/Toast/useToast", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/feedback/Toast/useToast")>()),
toast,
}));
beforeEach(() => {
otherRuns.current = fakeRuns().runs;
});
function setup(
gate: GateCode = "available",
over: Partial<AssistantCapability> = {},
remote: { state: "needs_you" | "working"; sid: string } | null = null,
) {
fake.current = fakeAssistant(gate, over, {}, 80, false, {}, { remoteLight: () => remote });
const wrapper = mount(AssistantHeaderButton, {
global: { plugins: [fakeRouter(), fakeVuex()] },
attachTo: document.body,
});
return { wrapper, store: fake.current.store, capability: fake.current.capability };
}
function session(store: any, title: string) {
const sid = store.createSession({
origin: { entryKind: "header_button", objectKind: "none", container: "sidebar" },
thread: "new",
});
store.setTitle(sid, raw(title), "renamed");
store.state.sessions[sid].turnMeta.turnId = `turn-${sid}`;
return sid;
}
const button = (wrapper: any) => wrapper.find('[data-test="menu-link-ai-item"]');
const tooltip = (wrapper: any) => wrapper.findComponent(OTooltip).props("content");
beforeEach(() => {
lg.value = true;
sessionStorage.clear();
localStorage.clear();
});
afterEach(() => {
document.body.innerHTML = "";
closeLockedPanel();
openAssistant.mockClear();
track.mockClear();
toast.mockClear();
});
describe("AssistantHeaderButton gates", () => {
it("reserves the slot with a skeleton until the gate resolves", () => {
const { wrapper } = setup("loading");
expect(button(wrapper).exists()).toBe(false);
expect(wrapper.find('[data-test="assistant-header-skeleton"]').exists()).toBe(true);
});
it("is locked with the lock in its name on OSS and opens the pitch", async () => {
const { wrapper } = setup("not_entitled");
const el = button(wrapper);
expect(el.attributes("data-state")).toBe("locked");
expect(el.attributes("aria-label")).toBe("O2 Assistant, Enterprise and Cloud feature");
expect(tooltip(wrapper)).toBe("O2 Assistant is part of OpenObserve Enterprise and Cloud");
expect(el.find(".rounded-default.absolute svg").exists()).toBe(true);
expect(wrapper.find('[data-test^="assistant-"]').exists()).toBe(false);
await el.trigger("click");
await flushPromises();
expect(
document.body.querySelector('[data-gate="not_entitled"][data-size="hero"]'),
).not.toBeNull();
expect(openAssistant).not.toHaveBeenCalled();
});
it("is hidden for a member and muted, never aria-disabled, for an admin when not configured", async () => {
expect(button(setup("not_configured", { isAdmin: false }).wrapper).exists()).toBe(false);
document.body.innerHTML = "";
const { wrapper } = setup("not_configured", { isAdmin: true });
const el = button(wrapper);
expect(el.attributes("data-state")).toBe("off");
expect(el.attributes("aria-label")).toBe("O2 Assistant, not set up");
expect(el.attributes("aria-disabled")).toBeUndefined();
expect(el.attributes("disabled")).toBeUndefined();
await el.trigger("click");
await flushPromises();
expect(document.body.querySelector('[data-gate="not_configured"]')).not.toBeNull();
});
it("closes the setup sheet below lg by Escape or × and returns focus to the button", async () => {
lg.value = false;
const { wrapper } = setup("not_configured", { isAdmin: true });
for (const close of ["escape", "button"] as const) {
await button(wrapper).trigger("click");
await flushPromises();
const sheet = document.body.querySelector<HTMLElement>(
'[data-test="assistant-header-gate-sheet"]',
);
expect(sheet?.querySelector('[data-gate="not_configured"]')).not.toBeNull();
if (close === "escape")
sheet!
.querySelector("button")!
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
else
document.body
.querySelector<HTMLElement>('[data-test="assistant-header-gate-sheet-close"]')!
.click();
await flushPromises();
expect(document.body.querySelector('[data-test="assistant-header-gate-sheet"]')).toBeNull();
expect(document.activeElement).toBe(button(wrapper).element);
}
// Every header button binds the one learn dialog, so a stale one would answer the next test's open.
wrapper.unmount();
});
it("reserves the button's phone-sized slot while loading", () => {
const classes = setup("loading")
.wrapper.get('[data-test="assistant-header-skeleton"]')
.classes();
expect(classes).toEqual(
expect.arrayContaining(["size-7.5", "max-md:size-11", "pointer-coarse:size-11"]),
);
});
it("is locked on G2 and opens the gate popover, never an empty container (D7 decision 2)", async () => {
const { wrapper } = setup("no_permission");
const el = button(wrapper);
expect(el.attributes("aria-label")).toBe("O2 Assistant, locked");
expect(tooltip(wrapper)).toBe("Ask an admin for AI access");
await el.trigger("click");
await flushPromises();
const gate = document.body.querySelector('[data-test="assistant-gate-state"]');
expect(gate?.getAttribute("data-size")).toBe("popover");
expect(gate?.getAttribute("data-gate")).toBe("no_permission");
// The popover is teleported to body, so it must carry the assistant roles itself (`90` §2.2).
const ask = gate!.querySelector<HTMLElement>('[data-test="assistant-gate-ask-admin"]')!;
expect(ask.closest("[data-assistant-surface]")).not.toBeNull();
expect(button(wrapper).element.closest("[data-assistant-surface]")).toBeNull();
ask.click();
await flushPromises();
const request = document.body.querySelector('[data-test="assistant-ask-admin"]');
expect(request?.closest("[data-assistant-surface]")).not.toBeNull();
expect(useLockedPanel().value).toBeNull();
expect(openAssistant).not.toHaveBeenCalled();
wrapper.unmount();
});
it("opens the locked gate as a sheet below md, and Learn what it does as a dialog", async () => {
lg.value = false;
const { wrapper } = setup("no_permission");
await button(wrapper).trigger("click");
await flushPromises();
const gate = document.body.querySelector('[data-test="assistant-gate-state"]');
expect(gate?.getAttribute("data-gate")).toBe("no_permission");
expect(gate?.getAttribute("data-size")).toBe("hero");
document.body.querySelector<HTMLElement>('[data-test="assistant-gate-learn-more"]')!.click();
await flushPromises();
expect(gateLearnOpen.value).toBe(true);
gateLearnOpen.value = false;
wrapper.unmount();
});
it("is muted with the unavailable name when the agent is down", () => {
const { wrapper } = setup("unhealthy");
expect(button(wrapper).attributes("aria-label")).toBe("O2 Assistant, unavailable");
expect(tooltip(wrapper)).toBe("O2 Assistant is unavailable right now");
});
});
describe("AssistantHeaderButton light", () => {
it("is a plain idle button with no aria-expanded or aria-pressed", async () => {
const { wrapper } = setup();
const el = button(wrapper);
expect(el.attributes("data-state")).toBe("idle");
expect(el.attributes("aria-label")).toBe("O2 Assistant");
expect(el.attributes("aria-expanded")).toBeUndefined();
expect(el.attributes("aria-pressed")).toBeUndefined();
expect(el.attributes("data-entry")).toBe("ai-entry-header-ask");
await el.trigger("click");
expect(openAssistant).toHaveBeenCalledWith(
expect.objectContaining({ entryKind: "header_button", entryVerb: "ask", intent: "open" }),
);
});
it("shows open with the neutral pressed border while a container is visible", async () => {
const { wrapper, store } = setup();
session(store, "Checkout");
store.open({ kind: "sidebar" }, { focus: "none" });
await flushPromises();
const el = button(wrapper);
expect(el.attributes("data-state")).toBe("open");
expect(el.attributes("aria-label")).toBe("O2 Assistant, open");
expect(el.classes()).toContain("border-border-strong");
expect(tooltip(wrapper)).toBe("O2 Assistant · Open");
});
it("shows working with the title, and a count for more than one", async () => {
const { wrapper, store } = setup();
const a = session(store, "Checkout 5xx");
store.state.sessions[a].turn = { state: "responding.writing" };
await flushPromises();
expect(button(wrapper).attributes("data-state")).toBe("working");
expect(button(wrapper).attributes("aria-label")).toBe("O2 Assistant, answering");
expect(tooltip(wrapper)).toBe("Answering: Checkout 5xx");
expect(
wrapper.find('[data-test="assistant-header-status-light"]').attributes("data-shape"),
).toBe("ring");
store.setActive(null);
const b = session(store, "Payments");
store.state.sessions[b].turn = { state: "queued" };
await flushPromises();
expect(tooltip(wrapper)).toBe("2 answers running");
});
it("names another tab's answer by its history row, else claims no title", async () => {
const remote = { state: "working" as const, sid: "0190ffff-c3d4-7e5f-8a9b-000000000001" };
const unknown = setup("available", {}, remote);
await flushPromises();
expect(button(unknown.wrapper).attributes("data-state")).toBe("working");
expect(tooltip(unknown.wrapper)).toBe("O2 Assistant, answering");
unknown.wrapper.unmount();
const known = setup("available", { persistence: false }, remote);
known.store.setIdentity({ userOrgKey: "k1", org: "default" });
fake.current.db.rows.set(1, {
id: 1,
timestamp: new Date().toISOString(),
title: "Remote chat",
messages: [{ role: "user", content: raw("q") }],
sessionId: remote.sid,
userOrgKey: "k1",
});
fake.current.history.ensureLoaded();
await flushPromises();
expect(tooltip(known.wrapper)).toBe("Answering: Remote chat");
});
it("shows no count badge for another tab's waiting chat", async () => {
const { wrapper } = setup("available", {}, { state: "needs_you", sid: "elsewhere" });
await flushPromises();
expect(button(wrapper).attributes("data-state")).toBe("needs_you");
expect(wrapper.find('[data-test="assistant-header-status-light"]').text()).toBe("");
});
it("ranks needs you above working and shows the count", async () => {
const { wrapper, store } = setup();
const a = session(store, "Running");
store.state.sessions[a].turn = { state: "queued" };
store.setActive(null);
const b = session(store, "Delete alerts");
store.state.sessions[b].turn = { state: "awaiting_approval", callId: "c1" };
await flushPromises();
expect(button(wrapper).attributes("data-state")).toBe("needs_you");
expect(button(wrapper).attributes("aria-label")).toBe("O2 Assistant, needs you, 1 waiting");
expect(tooltip(wrapper)).toBe("Waiting for your approval");
expect(wrapper.find('[data-test="assistant-header-status-light"]').text()).toBe("1");
});
it("names the visible needs-you count so the label matches the content", async () => {
const { wrapper, store } = setup();
for (const title of ["Delete alerts", "Mute monitors"]) {
const sid = session(store, title);
store.state.sessions[sid].turn = { state: "awaiting_approval", callId: title };
}
await flushPromises();
const count = wrapper.get('[data-test="assistant-header-status-light"]').text();
expect(count).toBe("2");
expect(button(wrapper).attributes("aria-label")).toBe(
`O2 Assistant, needs you, ${count} waiting`,
);
});
it("uses the one consent key when the decision is about paid usage", async () => {
const { wrapper, store } = setup();
const a = session(store, "Costly");
store.state.sessions[a].turn = { state: "awaiting_consent" };
await flushPromises();
expect(tooltip(wrapper)).toBe("Waiting for your decision on paid usage");
});
it("shows an unread answer, ready or failed, until a container shows it", async () => {
const { wrapper, store } = setup();
const a = session(store, "Latency");
store.state.sessions[a].turn = { state: "done", saved: "local" };
store.state.sessions[a].unread = true;
await flushPromises();
expect(button(wrapper).attributes("aria-label")).toBe("O2 Assistant, answer ready");
expect(tooltip(wrapper)).toBe("Answer ready: Latency");
store.state.sessions[a].turn = {
state: "failed",
error: { code: "unknown", phase: "post_admission", retryable: true, action: "retry" },
};
await flushPromises();
expect(button(wrapper).attributes("aria-label")).toBe("O2 Assistant, last answer failed");
expect(
wrapper.find('[data-test="assistant-header-status-light"]').attributes("data-shape"),
).toBe("glyph");
});
it("opens the container on the lit session", async () => {
const { wrapper, store } = setup();
const a = session(store, "Lit");
store.setActive(null);
session(store, "Other");
store.state.sessions[a].unread = true;
store.state.sessions[a].turn = { state: "done", saved: "local" };
await flushPromises();
await button(wrapper).trigger("click");
expect(store.state.shell.activeSid).toBe(a);
expect(openAssistant).toHaveBeenCalledWith(expect.objectContaining({ thread: "current" }));
});
it("fires ai_status_light once per change", async () => {
const { store } = setup();
const a = session(store, "x");
store.state.sessions[a].turn = { state: "queued" };
await flushPromises();
store.state.sessions[a].turn = { state: "connecting" };
await flushPromises();
expect(track.mock.calls.filter(([name]) => name === "ai_status_light")).toEqual([
["ai_status_light", { state: "working" }],
]);
});
});
describe("AssistantHeaderButton other orgs (PO B51)", () => {
const other = (over = {}) => runRow("x", { otherOrg: true, org: "acme", ...over });
it("counts another org's chats in the light and lists them on click, never opening one", async () => {
otherRuns.current = fakeRuns([other()]).runs;
const { wrapper } = setup();
expect(button(wrapper).attributes("data-state")).toBe("working");
await button(wrapper).trigger("click");
await flushPromises();
expect(otherRuns.current.requestOpen).toHaveBeenCalled();
expect(openAssistant).not.toHaveBeenCalled();
});
it("shows a waiting decision of another org as needs you, with its count", () => {
const waiting = other({ group: "needs_you", status: "needsApproval", request: "x" });
otherRuns.current = fakeRuns([waiting]).runs;
const { wrapper } = setup();
expect(button(wrapper).attributes("data-state")).toBe("needs_you");
expect(button(wrapper).attributes("aria-label")).toContain("1 waiting");
});
it("leaves a light of this org's chats alone", async () => {
otherRuns.current = fakeRuns([other()]).runs;
const { wrapper } = setup("available", {}, { state: "working", sid: "here" });
await button(wrapper).trigger("click");
await flushPromises();
expect(otherRuns.current.requestOpen).not.toHaveBeenCalled();
expect(openAssistant).toHaveBeenCalled();
});
it("counts one decision once, however many chats wait on it", async () => {
const waiting = { group: "needs_you", status: "needsApproval", request: "c" } as const;
otherRuns.current = fakeRuns([
runRow("a", waiting),
runRow("b", { ...waiting, request: "d" }),
]).runs;
const { wrapper } = setup("available", {}, { state: "needs_you", sid: "a" });
expect(button(wrapper).attributes("aria-label")).toContain("2 waiting");
});
});
describe("AssistantHeaderButton announcements and toasts", () => {
const region = (wrapper: any) =>
wrapper.get('[data-test="assistant-header-status-region"]').text();
it("speaks for a hidden session and stays quiet for the one a container shows", async () => {
const { wrapper, store } = setup();
const a = session(store, "Hidden");
store.state.sessions[a].turn = { state: "awaiting_approval", callId: "c" };
await flushPromises();
expect(region(wrapper)).toBe("O2 Assistant needs you: Hidden.");
document.body.innerHTML = "";
const shown = setup();
const b = session(shown.store, "Shown");
shown.store.open({ kind: "sidebar" }, { focus: "none" });
shown.store.state.sessions[b].turn = { state: "awaiting_approval", callId: "c" };
await flushPromises();
expect(region(shown.wrapper)).toBe("");
});
it("raises one silent, masked toast per hidden turn", async () => {
const { store } = setup();
const a = session(store, "Quiet");
store.state.sessions[a].turn = { state: "done", saved: "local" };
store.state.sessions[a].unread = true;
await flushPromises();
store.state.sessions[a].turnMeta.lastSeq = 3;
store.state.sessions[a].ui.unseen = 1;
await flushPromises();
expect(toast).toHaveBeenCalledTimes(1);
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
message: "Answer ready · Quiet",
mask: true,
live: "off",
timeout: 6000,
}),
);
toast.mock.calls[0][0].action.handler();
expect(track).toHaveBeenCalledWith("ai_toast", { kind: "ready", action: "open" });
expect(openAssistant).toHaveBeenCalledWith(expect.objectContaining({ entryKind: "toast" }));
});
it("raises no toast for an ephemeral helper or analysis turn", async () => {
const { store } = setup();
const sid = store.createSession({
origin: { entryKind: "object_verb", objectKind: "incident", container: "rca" },
ephemeral: true,
});
store.state.sessions[sid].turnMeta.turnId = "turn-rca";
store.state.sessions[sid].turn = { state: "done", saved: "local" };
store.state.sessions[sid].unread = true;
await flushPromises();
expect(toast).not.toHaveBeenCalled();
});
it("raises no toast for the session a container shows", async () => {
const { store } = setup();
const a = session(store, "Visible");
store.open({ kind: "sidebar" }, { focus: "none" });
store.state.sessions[a].turn = { state: "awaiting_approval", callId: "c" };
await flushPromises();
expect(toast).not.toHaveBeenCalled();
});
});