Skip to content

Commit 01bd652

Browse files
fix(ui): use shared StateBoundary on public repo-quality page
Replace hand-rolled loading/error text with StateBoundary, a content-shaped skeleton, and retry wired to refetch. Distinguish HTTP failure from opt-out. Closes #6821 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 97fe9c7 commit 01bd652

2 files changed

Lines changed: 281 additions & 126 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import type { ReactNode } from "react";
5+
6+
// #6821: public-repo-quality-page hand-rolled loading/error (plain <p>, no role="status"/alert, no retry).
7+
// Mirror changelog.test.tsx's QueryClientProvider + mocked apiFetch harness.
8+
const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() }));
9+
vi.mock("@/lib/api/request", () => ({
10+
apiFetch: (...args: unknown[]) => apiFetch(...args),
11+
notifyApiFailure: vi.fn(),
12+
notifyApiRecovered: vi.fn(),
13+
}));
14+
vi.mock("@/lib/api/origin", () => ({
15+
getApiOrigin: () => "https://api.example.test",
16+
}));
17+
18+
import { PublicRepoQualityPage } from "./public-repo-quality-page";
19+
20+
function renderWithClient(ui: ReactNode) {
21+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
22+
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
23+
}
24+
25+
const FIXTURE = {
26+
repoFullName: "JSONbored/loopover",
27+
generatedAt: "2026-07-17T00:00:00.000Z",
28+
gate: {
29+
blocked: 10,
30+
blockedThenMerged: 2,
31+
falsePositiveRate: 0.2,
32+
precisionPct: 80,
33+
topGateTypes: [
34+
{
35+
gateType: "linked_issue",
36+
blocked: 4,
37+
blockedThenMerged: 1,
38+
falsePositiveRate: 0.25,
39+
precisionPct: 75,
40+
},
41+
],
42+
},
43+
outcomes: { merged: 20, closed: 5, mergeRatioPct: 80 },
44+
slop: { totalResolved: 12, overallMergeRate: 50, discriminates: true },
45+
trend: [
46+
{
47+
weekStart: "2026-07-07",
48+
gateBlocked: 3,
49+
gateBlockedThenMerged: 1,
50+
gateFalsePositiveRate: 0.3,
51+
outcomesMerged: 4,
52+
outcomesClosed: 1,
53+
mergeRatioPct: 80,
54+
},
55+
],
56+
};
57+
58+
describe("PublicRepoQualityPage (#6821)", () => {
59+
afterEach(() => {
60+
apiFetch.mockReset();
61+
});
62+
63+
it("renders a content-shaped loading skeleton instead of the old plain loading text", () => {
64+
apiFetch.mockReturnValue(new Promise(() => {}));
65+
const { container } = renderWithClient(
66+
<PublicRepoQualityPage owner="JSONbored" repo="loopover" />,
67+
);
68+
69+
// REGRESSION: the old branch was a bare <p>Loading review-quality metrics…</p> with no skeleton.
70+
expect(screen.queryByText("Loading review-quality metrics…")).toBeNull();
71+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(1);
72+
});
73+
74+
it("renders an accessible error state (role=alert) with a retry that refetches", async () => {
75+
apiFetch.mockResolvedValue({
76+
ok: false,
77+
kind: "http",
78+
status: 503,
79+
message: "unavailable",
80+
durationMs: 10,
81+
});
82+
renderWithClient(<PublicRepoQualityPage owner="JSONbored" repo="loopover" />);
83+
84+
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
85+
expect(screen.getByText("Review quality unavailable")).toBeTruthy();
86+
87+
apiFetch.mockResolvedValueOnce({ ok: true, data: FIXTURE, status: 200, durationMs: 10 });
88+
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
89+
90+
await waitFor(() => expect(screen.getByText("JSONbored/loopover")).toBeTruthy());
91+
expect(screen.queryByRole("alert")).toBeNull();
92+
});
93+
94+
it("renders the empty-state copy when the repo has not opted in (null payload)", async () => {
95+
apiFetch.mockResolvedValue({ ok: true, data: null, status: 200, durationMs: 10 });
96+
renderWithClient(<PublicRepoQualityPage owner="JSONbored" repo="loopover" />);
97+
98+
await waitFor(() => expect(screen.getByText("Review quality unavailable")).toBeTruthy());
99+
// Empty (opt-out) is not an alert — that role is reserved for fetch failures with retry.
100+
expect(screen.queryByRole("alert")).toBeNull();
101+
});
102+
103+
it("renders the 3-stat cards once metrics load", async () => {
104+
apiFetch.mockResolvedValue({ ok: true, data: FIXTURE, status: 200, durationMs: 10 });
105+
renderWithClient(<PublicRepoQualityPage owner="JSONbored" repo="loopover" />);
106+
107+
await waitFor(() => expect(screen.getByText("Gate precision")).toBeTruthy());
108+
// "Merge ratio" appears on both the stat card and the trend table header.
109+
expect(screen.getAllByText("Merge ratio").length).toBeGreaterThanOrEqual(1);
110+
expect(screen.getByText("Slop calibration")).toBeTruthy();
111+
expect(screen.getByText("Weekly trend")).toBeTruthy();
112+
expect(screen.queryByRole("alert")).toBeNull();
113+
});
114+
});

0 commit comments

Comments
 (0)