Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ describe("DeadLetterQueuePanel", () => {
expect(screen.getByText("insufficient_role")).toBeTruthy();
});

// #8668: the panel's own errorTitle/errorDescription are fixed strings that always win over
// ErrorState's network-aware copy defaults, so a network-kind failure is only observable via the
// icon swap (WifiOff vs AlertTriangle) -- errorKind previously wasn't threaded through at all, so
// this rendered the generic icon even when the API was completely unreachable.
it("shows the offline (WifiOff) icon for a network-kind failure, not the generic one", async () => {
apiFetch.mockResolvedValue({ ok: false, message: "fetch failed", kind: "network" });
const { container } = render(<DeadLetterQueuePanel />);
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 failure", async () => {
apiFetch.mockResolvedValue({ ok: false, message: "insufficient_role", kind: "http" });
const { container } = render(<DeadLetterQueuePanel />);
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("shows an error state when the response is malformed", async () => {
apiFetch.mockResolvedValue({ ok: true, data: { generatedAt: "x" } });
render(<DeadLetterQueuePanel />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function DeadLetterQueuePanel() {
<StateBoundary
isLoading={resource.status === "loading"}
isError={resource.status === "error" || isMalformed}
errorKind={resource.status === "error" ? resource.errorKind : undefined}
isEmpty={page !== null && page.items.length === 0}
onRetry={resource.reload}
onRefresh={resource.reload}
Expand Down
77 changes: 75 additions & 2 deletions apps/loopover-ui/src/routes/app.index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";

import { TooltipProvider } from "@/components/ui/tooltip";
import { SparkStat } from "./app.index";

const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
vi.mock("@/lib/api/request", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock("@/lib/api/origin", () => ({ getApiOrigin: () => "https://api.test" }));

vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => () => ({}),
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
useNavigate: () => () => Promise.resolve(),
}));

import { AppOverview } from "./app.index";

function mockOverviewFetch(
overview: { ok: true } | { ok: false; kind?: "network" | "timeout" | "http" },
) {
apiFetch.mockImplementation((url: string) => {
if (url.endsWith("/v1/auth/session")) {
return Promise.resolve({
ok: true,
data: {
status: "authenticated",
login: "test-user",
roles: ["miner"],
confirmed_miner: false,
},
});
}
if (url.endsWith("/v1/app/overview")) {
return overview.ok
? Promise.resolve({ ok: true, data: { metrics: [], recentRuns: [] } })
: Promise.resolve({ ok: false, message: "fetch failed", kind: overview.kind });
}
return Promise.resolve({ ok: false, message: "unhandled in test" });
});
}

// #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.
describe("SparkStat loading state (#6984)", () => {
Expand Down Expand Up @@ -39,3 +76,39 @@ describe("SparkStat loading state (#6984)", () => {
expect(screen.queryByRole("status", { name: "Loading Open PRs" })).toBeNull();
});
});

// #8668: the overview metrics ErrorState passed only title/description (both fixed strings), never
// errorKind or onRetry -- so a network outage always rendered the generic AlertTriangle treatment
// with no retry action, even though `overview.errorKind`/`overview.reload` were both already
// available from useApiResource.
describe("AppOverview metrics error state (#8668)", () => {
it("shows the offline (WifiOff) icon for a network-kind overview failure, not the generic one", async () => {
mockOverviewFetch({ ok: false, kind: "network" });
const { container } = render(<AppOverview />);
await screen.findByText("App overview is unavailable right now");
expect(container.querySelector(".lucide-wifi-off")).toBeTruthy();
expect(container.querySelector(".lucide-triangle-alert")).toBeNull();
});

it("keeps the generic (AlertTriangle) icon for a non-network overview failure", async () => {
mockOverviewFetch({ ok: false, kind: "http" });
const { container } = render(<AppOverview />);
await screen.findByText("App overview is unavailable right now");
expect(container.querySelector(".lucide-triangle-alert")).toBeTruthy();
expect(container.querySelector(".lucide-wifi-off")).toBeNull();
});

it("retrying the overview error re-fetches /v1/app/overview", async () => {
mockOverviewFetch({ ok: false, kind: "network" });
render(<AppOverview />);
await screen.findByText("App overview is unavailable right now");

apiFetch.mockClear();
mockOverviewFetch({ ok: false, kind: "network" });
fireEvent.click(screen.getByRole("button", { name: "Try again" }));

await waitFor(() =>
expect(apiFetch).toHaveBeenCalledWith("https://api.test/v1/app/overview", expect.any(Object)),
);
});
});
4 changes: 3 additions & 1 deletion apps/loopover-ui/src/routes/app.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ type AppOverviewResponse = {
}>;
};

function AppOverview() {
export function AppOverview() {
const { session } = useSession();
const { status, connection } = useApiStatus();
const overview = useApiResource<AppOverviewResponse>("/v1/app/overview", "App overview");
Expand Down Expand Up @@ -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 ? (
Expand Down
Loading