353 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|