diff --git a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx index f299d52f69..a55dfb58be 100644 --- a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx +++ b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.test.tsx @@ -159,6 +159,22 @@ describe("DeadLetterQueuePanel", () => { ).toBeTruthy(); }); + it("REGRESSION (#8668): shows the WifiOff icon, not the generic AlertTriangle, when the fetch fails with a network errorKind", async () => { + apiFetch.mockResolvedValue({ ok: false, kind: "network", message: "fetch failed" }); + const { container } = render(); + await screen.findByText("Couldn't load the dead-letter queue"); + expect(container.querySelector(".lucide-wifi-off")).toBeTruthy(); + expect(container.querySelector(".lucide-triangle-alert")).toBeNull(); + }); + + it("keeps the generic AlertTriangle icon for a non-network (e.g. http) errorKind", async () => { + apiFetch.mockResolvedValue({ ok: false, kind: "http", message: "insufficient_role" }); + const { container } = render(); + await screen.findByText("Couldn't load the dead-letter queue"); + expect(container.querySelector(".lucide-triangle-alert")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeNull(); + }); + it("expands and collapses a truncated error message", async () => { const longError = "x".repeat(120); apiFetch.mockResolvedValue({ diff --git a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx index 63e4152c82..714992c44c 100644 --- a/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx +++ b/apps/loopover-ui/src/components/site/dead-letter-queue-panel.tsx @@ -120,6 +120,7 @@ export function DeadLetterQueuePanel() { ({ + createFileRoute: () => () => ({}), + useNavigate: () => () => Promise.resolve(), + Link: ({ to, children }: { to: string; children: ReactNode }) => {children}, +})); + +const { useSession } = vi.hoisted(() => ({ useSession: vi.fn() })); +vi.mock("@/lib/api/session", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useSession: () => useSession() }; +}); + +const { useApiResource } = vi.hoisted(() => ({ useApiResource: vi.fn() })); +vi.mock("@/lib/api/use-api-resource", () => ({ + useApiResource: (...args: unknown[]) => useApiResource(...args), +})); // #6984: SparkStat's loading branch hand-rolled its own animate-pulse divs instead of the shared // Skeleton primitive every other loading placeholder in this app already uses. @@ -39,3 +57,61 @@ describe("SparkStat loading state (#6984)", () => { expect(screen.queryByRole("status", { name: "Loading Open PRs" })).toBeNull(); }); }); + +describe("AppOverview error state (#8668)", () => { + const SESSION = { + login: "octo", + roles: ["miner"] as const, + confirmed_miner: true, + }; + + it("REGRESSION: shows the WifiOff icon, not the generic AlertTriangle, when the overview fetch fails with a network errorKind", () => { + useSession.mockReturnValue({ session: SESSION }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "fetch failed", + errorKind: "network", + loadedAt: null, + reload: vi.fn(), + }); + + const { container } = render(); + expect(screen.getByText("App overview is unavailable right now")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeTruthy(); + expect(container.querySelector(".lucide-triangle-alert")).toBeNull(); + }); + + it("keeps the generic AlertTriangle icon for a non-network (e.g. http) errorKind", () => { + useSession.mockReturnValue({ session: SESSION }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "server exploded", + errorKind: "http", + loadedAt: null, + reload: vi.fn(), + }); + + const { container } = render(); + expect(container.querySelector(".lucide-triangle-alert")).toBeTruthy(); + expect(container.querySelector(".lucide-wifi-off")).toBeNull(); + }); + + it("REGRESSION: clicking retry on the overview error state re-triggers the fetch", () => { + const reload = vi.fn(); + useSession.mockReturnValue({ session: SESSION }); + useApiResource.mockReturnValue({ + status: "error", + data: null, + error: "fetch failed", + errorKind: "network", + loadedAt: null, + reload, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /try again/i })); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/loopover-ui/src/routes/app.index.tsx b/apps/loopover-ui/src/routes/app.index.tsx index 687bf082d2..27b8acd83d 100644 --- a/apps/loopover-ui/src/routes/app.index.tsx +++ b/apps/loopover-ui/src/routes/app.index.tsx @@ -102,7 +102,7 @@ type AppOverviewResponse = { }>; }; -function AppOverview() { +export function AppOverview() { const { session } = useSession(); const { status, connection } = useApiStatus(); const overview = useApiResource("/v1/app/overview", "App overview"); @@ -175,6 +175,8 @@ function AppOverview() { className="col-span-full" title="App overview is unavailable right now" description={overview.error} + errorKind={overview.errorKind} + onRetry={overview.reload} /> )} {series.length === 0 ? (