Skip to content

Commit ffafa2f

Browse files
committed
feat(shell-dashboard): add showcase-ops API client and probe hooks for Status tab
Provides the data layer for the dashboard's Status tab against the showcase-ops HTTP contract being built in parallel (B3). - lib/ops-api.ts: types + fetchProbes / fetchProbeDetail / triggerProbe. baseUrl resolves explicit param → NEXT_PUBLIC_OPS_BASE_URL → /api/ops. Trigger sends Bearer token from caller; URL-encodes ids; uniform status-line errors on non-2xx. - hooks/use-probes.ts: useProbes / useProbeDetail / useTriggerProbe. 10s default polling, AbortController cancels in-flight on unmount or interval rollover, AbortError suppressed from user-visible state. 33 new tests (red→green), full shell-dashboard suite green (163/164, 1 pre-existing skip).
1 parent ac28335 commit ffafa2f

4 files changed

Lines changed: 1040 additions & 0 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* Tests for `useProbes`, `useProbeDetail`, and `useTriggerProbe`.
3+
*
4+
* These hooks wrap the `lib/ops-api` client and add 10s polling +
5+
* AbortController-based cancellation. We mock the client module so the
6+
* tests focus on hook semantics (interval cadence, abort-on-unmount,
7+
* error surfaces, refetch).
8+
*/
9+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
10+
import { renderHook, act, waitFor } from "@testing-library/react";
11+
12+
import type {
13+
ProbesResponse,
14+
ProbeScheduleEntry,
15+
ProbeRun,
16+
TriggerResponse,
17+
} from "../lib/ops-api";
18+
19+
// Mocked module — the hooks under test consume this.
20+
vi.mock("../lib/ops-api", () => {
21+
return {
22+
fetchProbes: vi.fn(),
23+
fetchProbeDetail: vi.fn(),
24+
triggerProbe: vi.fn(),
25+
};
26+
});
27+
28+
import * as opsApi from "../lib/ops-api";
29+
import {
30+
useProbes,
31+
useProbeDetail,
32+
useTriggerProbe,
33+
} from "./use-probes";
34+
35+
const fetchProbesMock = opsApi.fetchProbes as unknown as ReturnType<typeof vi.fn>;
36+
const fetchProbeDetailMock = opsApi.fetchProbeDetail as unknown as ReturnType<
37+
typeof vi.fn
38+
>;
39+
const triggerProbeMock = opsApi.triggerProbe as unknown as ReturnType<typeof vi.fn>;
40+
41+
function emptyProbes(): ProbesResponse {
42+
return { probes: [] };
43+
}
44+
45+
function entry(id: string): ProbeScheduleEntry {
46+
return {
47+
id,
48+
kind: id,
49+
schedule: "*/5 * * * *",
50+
nextRunAt: null,
51+
lastRun: null,
52+
inflight: null,
53+
config: { timeout_ms: 60_000, max_concurrency: 5, discovery: null },
54+
};
55+
}
56+
57+
function run(id: string): ProbeRun {
58+
return {
59+
id,
60+
probeId: "smoke",
61+
startedAt: "2026-04-25T11:55:00Z",
62+
finishedAt: "2026-04-25T11:55:30Z",
63+
durationMs: 30_000,
64+
triggered: false,
65+
summary: { total: 1, passed: 1, failed: 0 },
66+
};
67+
}
68+
69+
beforeEach(() => {
70+
fetchProbesMock.mockReset();
71+
fetchProbeDetailMock.mockReset();
72+
triggerProbeMock.mockReset();
73+
});
74+
75+
afterEach(() => {
76+
vi.useRealTimers();
77+
});
78+
79+
describe("useProbes", () => {
80+
it("fetches once on mount and exposes the data", async () => {
81+
fetchProbesMock.mockResolvedValue({ probes: [entry("smoke")] });
82+
const { result } = renderHook(() => useProbes());
83+
expect(result.current.loading).toBe(true);
84+
await waitFor(() =>
85+
expect(result.current.data?.probes).toHaveLength(1),
86+
);
87+
expect(result.current.loading).toBe(false);
88+
expect(fetchProbesMock).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it("polls on the configured interval (default 10s)", async () => {
92+
vi.useFakeTimers();
93+
fetchProbesMock.mockResolvedValue(emptyProbes());
94+
const { result } = renderHook(() => useProbes());
95+
await vi.waitFor(() => expect(result.current.data).not.toBeNull());
96+
expect(fetchProbesMock).toHaveBeenCalledTimes(1);
97+
98+
await act(async () => {
99+
await vi.advanceTimersByTimeAsync(10_000);
100+
});
101+
expect(fetchProbesMock).toHaveBeenCalledTimes(2);
102+
103+
await act(async () => {
104+
await vi.advanceTimersByTimeAsync(10_000);
105+
});
106+
expect(fetchProbesMock).toHaveBeenCalledTimes(3);
107+
});
108+
109+
it("honors a custom intervalMs", async () => {
110+
vi.useFakeTimers();
111+
fetchProbesMock.mockResolvedValue(emptyProbes());
112+
const { result } = renderHook(() => useProbes({ intervalMs: 1000 }));
113+
await vi.waitFor(() => expect(result.current.data).not.toBeNull());
114+
const before = fetchProbesMock.mock.calls.length;
115+
await act(async () => {
116+
await vi.advanceTimersByTimeAsync(3_000);
117+
});
118+
expect(fetchProbesMock.mock.calls.length).toBeGreaterThanOrEqual(
119+
before + 3,
120+
);
121+
});
122+
123+
it("surfaces errors on the `error` field without throwing", async () => {
124+
fetchProbesMock.mockRejectedValue(new Error("boom"));
125+
const { result } = renderHook(() => useProbes());
126+
await waitFor(() => expect(result.current.error).not.toBeNull());
127+
expect(result.current.error?.message).toBe("boom");
128+
expect(result.current.loading).toBe(false);
129+
});
130+
131+
it("refetch triggers an immediate fetch", async () => {
132+
fetchProbesMock.mockResolvedValue(emptyProbes());
133+
const { result } = renderHook(() => useProbes());
134+
await waitFor(() => expect(result.current.data).not.toBeNull());
135+
expect(fetchProbesMock).toHaveBeenCalledTimes(1);
136+
await act(async () => {
137+
await result.current.refetch();
138+
});
139+
expect(fetchProbesMock).toHaveBeenCalledTimes(2);
140+
});
141+
142+
it("aborts inflight fetch on unmount", async () => {
143+
fetchProbesMock.mockImplementation(
144+
({ signal }: { signal?: AbortSignal } = {}) =>
145+
new Promise<ProbesResponse>((_resolve, reject) => {
146+
signal?.addEventListener("abort", () => {
147+
reject(new DOMException("aborted", "AbortError"));
148+
});
149+
}),
150+
);
151+
const { unmount } = renderHook(() => useProbes());
152+
await waitFor(() => expect(fetchProbesMock).toHaveBeenCalled());
153+
const init = fetchProbesMock.mock.calls[0]![0] as {
154+
signal?: AbortSignal;
155+
};
156+
expect(init.signal).toBeDefined();
157+
unmount();
158+
expect(init.signal?.aborted).toBe(true);
159+
});
160+
161+
it("does not surface a state update after unmount", async () => {
162+
let resolve: ((v: ProbesResponse) => void) | null = null;
163+
fetchProbesMock.mockImplementation(
164+
() =>
165+
new Promise<ProbesResponse>((r) => {
166+
resolve = r;
167+
}),
168+
);
169+
const { result, unmount } = renderHook(() => useProbes());
170+
await waitFor(() => expect(fetchProbesMock).toHaveBeenCalled());
171+
unmount();
172+
// After unmount, resolve the pending fetch; the hook must not crash.
173+
resolve!(emptyProbes());
174+
// No assertion on result.current after unmount — just confirm no throw.
175+
expect(result.current.data).toBeNull();
176+
});
177+
178+
it("forwards baseUrl to the client", async () => {
179+
fetchProbesMock.mockResolvedValue(emptyProbes());
180+
renderHook(() => useProbes({ baseUrl: "http://ops.test" }));
181+
await waitFor(() => expect(fetchProbesMock).toHaveBeenCalled());
182+
const arg = fetchProbesMock.mock.calls[0]![0] as { baseUrl?: string };
183+
expect(arg.baseUrl).toBe("http://ops.test");
184+
});
185+
});
186+
187+
describe("useProbeDetail", () => {
188+
it("does NOT fetch when id is null", async () => {
189+
const { result } = renderHook(() => useProbeDetail(null));
190+
// Give microtasks a chance to flush.
191+
await Promise.resolve();
192+
expect(fetchProbeDetailMock).not.toHaveBeenCalled();
193+
expect(result.current.data).toBeNull();
194+
expect(result.current.loading).toBe(false);
195+
});
196+
197+
it("fetches when id is provided and exposes data", async () => {
198+
fetchProbeDetailMock.mockResolvedValue({
199+
probe: entry("smoke"),
200+
runs: [run("r1")],
201+
});
202+
const { result } = renderHook(() => useProbeDetail("smoke"));
203+
await waitFor(() => expect(result.current.data).not.toBeNull());
204+
expect(result.current.data?.probe.id).toBe("smoke");
205+
expect(result.current.data?.runs).toHaveLength(1);
206+
});
207+
208+
it("refetches on id change and aborts previous request", async () => {
209+
fetchProbeDetailMock.mockResolvedValue({
210+
probe: entry("smoke"),
211+
runs: [],
212+
});
213+
const { rerender } = renderHook(
214+
({ id }: { id: string | null }) => useProbeDetail(id),
215+
{ initialProps: { id: "smoke" as string | null } },
216+
);
217+
await waitFor(() => expect(fetchProbeDetailMock).toHaveBeenCalled());
218+
const firstSignal = (
219+
fetchProbeDetailMock.mock.calls[0]![1] as { signal?: AbortSignal }
220+
).signal;
221+
222+
fetchProbeDetailMock.mockResolvedValue({
223+
probe: entry("e2e_demos"),
224+
runs: [],
225+
});
226+
rerender({ id: "e2e_demos" });
227+
await waitFor(() =>
228+
expect(fetchProbeDetailMock.mock.calls.length).toBeGreaterThanOrEqual(2),
229+
);
230+
// Previous signal must have been aborted by the id change.
231+
expect(firstSignal?.aborted).toBe(true);
232+
});
233+
234+
it("polls on the configured interval", async () => {
235+
vi.useFakeTimers();
236+
fetchProbeDetailMock.mockResolvedValue({
237+
probe: entry("smoke"),
238+
runs: [],
239+
});
240+
const { result } = renderHook(() =>
241+
useProbeDetail("smoke", { intervalMs: 1000 }),
242+
);
243+
await vi.waitFor(() => expect(result.current.data).not.toBeNull());
244+
expect(fetchProbeDetailMock).toHaveBeenCalledTimes(1);
245+
await act(async () => {
246+
await vi.advanceTimersByTimeAsync(2_500);
247+
});
248+
expect(fetchProbeDetailMock.mock.calls.length).toBeGreaterThanOrEqual(3);
249+
});
250+
251+
it("surfaces errors on `error` field", async () => {
252+
fetchProbeDetailMock.mockRejectedValue(new Error("nope"));
253+
const { result } = renderHook(() => useProbeDetail("smoke"));
254+
await waitFor(() => expect(result.current.error).not.toBeNull());
255+
expect(result.current.error?.message).toBe("nope");
256+
});
257+
});
258+
259+
describe("useTriggerProbe", () => {
260+
function triggerOk(): TriggerResponse {
261+
return {
262+
runId: "run-1",
263+
status: "queued",
264+
probe: "smoke",
265+
scope: [],
266+
};
267+
}
268+
269+
it("invokes triggerProbe with id, slugs, token", async () => {
270+
triggerProbeMock.mockResolvedValue(triggerOk());
271+
const { result } = renderHook(() =>
272+
useTriggerProbe({ token: "secret", baseUrl: "http://ops.test" }),
273+
);
274+
const captured: { value: TriggerResponse | null } = { value: null };
275+
await act(async () => {
276+
captured.value = await result.current.trigger("smoke", ["agno"]);
277+
});
278+
expect(captured.value?.runId).toBe("run-1");
279+
expect(triggerProbeMock).toHaveBeenCalledWith("smoke", {
280+
slugs: ["agno"],
281+
token: "secret",
282+
baseUrl: "http://ops.test",
283+
});
284+
});
285+
286+
it("tracks pending state across the call", async () => {
287+
let resolve: ((v: TriggerResponse) => void) | null = null;
288+
triggerProbeMock.mockImplementation(
289+
() =>
290+
new Promise<TriggerResponse>((r) => {
291+
resolve = r;
292+
}),
293+
);
294+
const { result } = renderHook(() =>
295+
useTriggerProbe({ token: "t" }),
296+
);
297+
expect(result.current.pending).toBe(false);
298+
let p: Promise<TriggerResponse>;
299+
act(() => {
300+
p = result.current.trigger("smoke");
301+
});
302+
await waitFor(() => expect(result.current.pending).toBe(true));
303+
act(() => {
304+
resolve!(triggerOk());
305+
});
306+
await act(async () => {
307+
await p!;
308+
});
309+
await waitFor(() => expect(result.current.pending).toBe(false));
310+
});
311+
312+
it("surfaces error and rejects the call", async () => {
313+
triggerProbeMock.mockRejectedValue(new Error("forbidden"));
314+
const { result } = renderHook(() => useTriggerProbe({ token: "t" }));
315+
let caught: unknown = null;
316+
await act(async () => {
317+
try {
318+
await result.current.trigger("smoke");
319+
} catch (err) {
320+
caught = err;
321+
}
322+
});
323+
expect((caught as Error)?.message).toBe("forbidden");
324+
await waitFor(() =>
325+
expect(result.current.error?.message).toBe("forbidden"),
326+
);
327+
expect(result.current.pending).toBe(false);
328+
});
329+
330+
it("throws if invoked without a token", async () => {
331+
const { result } = renderHook(() => useTriggerProbe());
332+
let caught: unknown = null;
333+
await act(async () => {
334+
try {
335+
await result.current.trigger("smoke");
336+
} catch (err) {
337+
caught = err;
338+
}
339+
});
340+
expect((caught as Error)?.message).toMatch(/token/i);
341+
});
342+
});

0 commit comments

Comments
 (0)