Files
fusion/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx
2026-07-08 12:47:13 -04:00

353 lines
12 KiB
TypeScript

import { useRef } from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { RuntimeFallbackBadge } from "../RuntimeFallbackBadge";
import { ToastProvider, useToast } from "../../hooks/useToast";
import { __resetRuntimeFallbackToastDedupeStoreForTests } from "../../hooks/useRuntimeFallbackStatus";
import type { TaskRuntimeFallbackResponse } from "../../api/legacy";
const legacyMocks = vi.hoisted(() => ({
fetchTaskRuntimeFallback: vi.fn(),
}));
vi.mock("../../api/legacy", () => legacyMocks);
// Toasts auto-dismiss after 4s (useToast's internal timer). To assert "fired
// exactly once" across a longer window we must count every toast ever
// appended, not just the currently-visible set, so track full history here.
function ToastPeek() {
const { toasts } = useToast();
const historyRef = useRef<typeof toasts>([]);
const seenIds = useRef(new Set<number>());
for (const toast of toasts) {
if (!seenIds.current.has(toast.id)) {
seenIds.current.add(toast.id);
historyRef.current.push(toast);
}
}
return (
<div data-testid="toast-peek">
{historyRef.current.map((t) => (
<div key={t.id} data-testid="toast-entry" data-type={t.type}>{t.message}</div>
))}
</div>
);
}
function renderBadge(taskId = "FN-100", isInViewport = true) {
return render(
<ToastProvider>
<RuntimeFallbackBadge taskId={taskId} isInViewport={isInViewport} projectId="proj-1" />
<ToastPeek />
</ToastProvider>,
);
}
const noEvent: TaskRuntimeFallbackResponse = {
taskId: "FN-100",
hasEvent: false,
wasConfigured: null,
runtimeHint: null,
reason: null,
eventId: null,
timestamp: null,
showFallbackBadge: false,
};
const configuredOk: TaskRuntimeFallbackResponse = {
...noEvent,
hasEvent: true,
wasConfigured: true,
runtimeHint: "hermes",
eventId: "audit-ok",
timestamp: "2026-07-08T00:00:00.000Z",
showFallbackBadge: false,
};
const fallbackBlankHint: TaskRuntimeFallbackResponse = {
...noEvent,
hasEvent: true,
wasConfigured: false,
runtimeHint: null,
eventId: "audit-blank",
timestamp: "2026-07-08T00:00:00.000Z",
showFallbackBadge: false,
};
const fallbackWithHint: TaskRuntimeFallbackResponse = {
taskId: "FN-100",
hasEvent: true,
wasConfigured: false,
runtimeHint: "hermes",
reason: "not_found",
eventId: "audit-fallback-1",
timestamp: "2026-07-08T00:00:00.000Z",
showFallbackBadge: true,
};
describe("RuntimeFallbackBadge", () => {
beforeEach(() => {
vi.useFakeTimers();
legacyMocks.fetchTaskRuntimeFallback.mockReset();
// The toast dedupe store is module-level/shared by design (that is the
// fix under test) — reset it between test cases so one test's "already
// toasted" state does not leak into the next.
__resetRuntimeFallbackToastDedupeStoreForTests();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders nothing when no session:runtime-resolved event exists yet", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(noEvent);
renderBadge();
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
});
it("renders nothing when the most recent event has wasConfigured=true", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(configuredOk);
renderBadge();
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
});
it("renders nothing when wasConfigured=false but runtimeHint is blank/absent", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackBlankHint);
renderBadge();
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
});
it("renders the badge when wasConfigured=false and runtimeHint is non-empty", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
renderBadge();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const badge = screen.getByTestId("runtime-fallback-badge");
expect(badge.textContent).toContain("hermes");
expect(badge.textContent).toContain("unavailable");
expect(badge.getAttribute("data-runtime-hint")).toBe("hermes");
expect(badge.getAttribute("data-runtime-fallback-reason")).toBe("not_found");
});
it("does not resurrect the badge when a stale wasConfigured=false event is superseded by a newer success", async () => {
// Simulates: latest-event endpoint already reflects only the newest event,
// so a superseded older fallback never reaches the component as `showFallbackBadge`.
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(configuredOk);
renderBadge();
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
});
it("fires a toast exactly once for a newly-observed fallback session, not on every poll", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
renderBadge();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes");
// Advance past several poll intervals with the *same* event still being the latest;
// the toast must not fire again.
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
});
it("does not poll (and renders nothing) when isInViewport is false", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
renderBadge("FN-100", false);
await act(async () => {
await Promise.resolve();
});
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
});
// ActiveAgentsPanel.tsx and AgentsView.tsx (board + list cards) each wire a
// real IntersectionObserver-backed isInViewport value into this exact same
// <RuntimeFallbackBadge isInViewport={...} /> call, mirroring TaskCard.tsx's
// pattern -- the shared poll-gating implementation lives here in the hook
// this component consumes, so a *transition* (not just a static isInViewport
// prop) is what actually reproduces "card scrolls off-screen mid-session"
// for all four call sites, not just the initial-render case above.
it("stops polling once isInViewport transitions to false mid-session, and resumes once it transitions back to true", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
const { rerender } = render(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
</ToastProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument();
// Card scrolls off-screen: parent flips isInViewport to false (as a real
// IntersectionObserver callback would via setIsInViewport(false)).
rerender(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={false} projectId="proj-1" />
</ToastProvider>,
);
legacyMocks.fetchTaskRuntimeFallback.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
// Card scrolls back into view: polling resumes.
rerender(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
</ToastProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled();
expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument();
});
it("fires exactly one toast when the same task/event is observed by two simultaneously-mounted badge instances (e.g. ActiveAgentsPanel + AgentsView rendering the same task concurrently)", async () => {
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
render(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
<ToastPeek />
</ToastProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Both instances polled and both observed the same new eventId on their
// first poll, but the shared module-level dedupe store means only one of
// them should have won the "claim" and fired a toast.
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes");
// Both badges still render independently (dedupe only affects the toast,
// not the per-instance badge display).
expect(screen.getAllByTestId("runtime-fallback-badge")).toHaveLength(2);
// Further polls with the same event on both instances must not add a
// second toast either.
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
});
});
describe("RuntimeFallbackBadge — mobile breakpoint", () => {
beforeEach(() => {
vi.useFakeTimers();
legacyMocks.fetchTaskRuntimeFallback.mockReset();
__resetRuntimeFallbackToastDedupeStoreForTests();
});
afterEach(() => {
vi.useRealTimers();
});
function mockMobileViewport() {
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 812, configurable: true });
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn() });
}
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: true,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
it("still renders the badge with its message and data attributes at mobile viewport width", async () => {
mockMobileViewport();
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
renderBadge();
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const badge = screen.getByTestId("runtime-fallback-badge");
expect(badge).toBeInTheDocument();
expect(badge.textContent).toContain("hermes");
expect(badge.className).toContain("card-runtime-fallback-badge");
});
it("stops polling once isInViewport transitions to false at mobile viewport width (agent-card list rows scroll off-screen too)", async () => {
mockMobileViewport();
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
const { rerender } = render(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
</ToastProvider>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1);
rerender(
<ToastProvider>
<RuntimeFallbackBadge taskId="FN-100" isInViewport={false} projectId="proj-1" />
</ToastProvider>,
);
legacyMocks.fetchTaskRuntimeFallback.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
});
});