Skip to content

Commit df5fa2d

Browse files
committed
fix(showcase-dashboard): tighten error semantics and trigger return type in ops hooks
R3-C.1: useTriggerProbe.trigger now returns Promise<TriggerResponse | null>. The supersession path resolves to `null` (typed) instead of the previous `undefined as unknown as TriggerResponse` cast, which lied about the return shape and would crash any caller that read fields off the result. R3-C.2: useProbeDetail clears `error` on id change so the detail panel no longer renders a stale error message under a freshly-selected probe header. R3-C.3: useProbes clears `error` on dep change (e.g. baseUrl swap) so a prior dep tuple's failure does not persist under the new one. R3-C.4: useTriggerProbe clears `error` at the start of each trigger call so a successful retry leaves no sticky error from the prior failure. Bonus: parseJson now attaches the original parse error as `cause` on the wrapper so debuggers can walk back to the SyntaxError name/stack without re-parsing the wrapped message. Tests: red-green tests added for each behavior; full file suite 49/49.
1 parent 17edacc commit df5fa2d

3 files changed

Lines changed: 182 additions & 19 deletions

File tree

showcase/shell-dashboard/src/hooks/use-probes.test.ts

Lines changed: 144 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,37 @@ describe("useProbes", () => {
181181
expect(arg.baseUrl).toBe("http://ops.test");
182182
});
183183

184+
it("clears stale error on baseUrl change (R3-C.3)", async () => {
185+
// baseUrl A errors → error set. Switch to baseUrl B (success) → error
186+
// must be cleared before B's fetch resolves so consumers don't render
187+
// a stale error against a fresh dep tuple.
188+
fetchProbesMock.mockRejectedValueOnce(new Error("a failed"));
189+
let resolveB: ((v: ProbesResponse) => void) | null = null;
190+
fetchProbesMock.mockImplementationOnce(
191+
() =>
192+
new Promise<ProbesResponse>((r) => {
193+
resolveB = r;
194+
}),
195+
);
196+
197+
const { result, rerender } = renderHook(
198+
({ baseUrl }: { baseUrl: string }) => useProbes({ baseUrl }),
199+
{ initialProps: { baseUrl: "http://a.test" } },
200+
);
201+
await waitFor(() => expect(result.current.error?.message).toBe("a failed"));
202+
203+
// Swap baseUrl — error must clear before B resolves.
204+
rerender({ baseUrl: "http://b.test" });
205+
await waitFor(() => expect(result.current.error).toBeNull());
206+
207+
// Resolve B — still no error.
208+
await act(async () => {
209+
resolveB!(emptyProbes());
210+
await Promise.resolve();
211+
});
212+
expect(result.current.error).toBeNull();
213+
});
214+
184215
it("does not setData with stale data when deps change rapidly (CR-B1.3)", async () => {
185216
// Drive a sequence where the first effect's fetch resolves AFTER the
186217
// dep change has triggered a new effect. With the old aliveRef pattern,
@@ -339,6 +370,42 @@ describe("useProbeDetail", () => {
339370
await waitFor(() => expect(result.current.data?.probe.id).toBe("deep"));
340371
});
341372

373+
it("clears stale error on id change (R3-C.2)", async () => {
374+
// First id errors → error populated. Switch id → error must clear before
375+
// the new fetch resolves so the panel never renders a stale error
376+
// beneath a different probe header.
377+
fetchProbeDetailMock.mockRejectedValueOnce(new Error("first failed"));
378+
let resolveSecond:
379+
| ((v: { probe: ProbeScheduleEntry; runs: ProbeRun[] }) => void)
380+
| null = null;
381+
fetchProbeDetailMock.mockImplementationOnce(
382+
() =>
383+
new Promise<{ probe: ProbeScheduleEntry; runs: ProbeRun[] }>((r) => {
384+
resolveSecond = r;
385+
}),
386+
);
387+
388+
const { result, rerender } = renderHook(
389+
({ id }: { id: string | null }) => useProbeDetail(id),
390+
{ initialProps: { id: "smoke" as string | null } },
391+
);
392+
await waitFor(() =>
393+
expect(result.current.error?.message).toBe("first failed"),
394+
);
395+
396+
// Switch id — error must clear immediately (before the second fetch
397+
// resolves).
398+
rerender({ id: "deep" });
399+
await waitFor(() => expect(result.current.error).toBeNull());
400+
401+
// Resolve second fetch — still no error.
402+
await act(async () => {
403+
resolveSecond!({ probe: entry("deep"), runs: [] });
404+
await Promise.resolve();
405+
});
406+
expect(result.current.error).toBeNull();
407+
});
408+
342409
it("does not setData with stale data when id changes rapidly (CR-B1.3)", async () => {
343410
let resolveA:
344411
| ((v: { probe: ProbeScheduleEntry; runs: ProbeRun[] }) => void)
@@ -424,7 +491,7 @@ describe("useTriggerProbe", () => {
424491
);
425492
const { result } = renderHook(() => useTriggerProbe({ token: "t" }));
426493
expect(result.current.pending).toBe(false);
427-
let p: Promise<TriggerResponse>;
494+
let p: Promise<TriggerResponse | null>;
428495
act(() => {
429496
p = result.current.trigger("smoke");
430497
});
@@ -490,10 +557,10 @@ describe("useTriggerProbe", () => {
490557
const { result, unmount } = renderHook(() =>
491558
useTriggerProbe({ token: "t" }),
492559
);
493-
let triggerPromise: Promise<TriggerResponse> | null = null;
560+
let triggerPromise: Promise<TriggerResponse | null> | null = null;
494561
act(() => {
495562
triggerPromise = result.current.trigger("smoke");
496-
triggerPromise.catch(() => {});
563+
triggerPromise?.catch(() => {});
497564
});
498565
await waitFor(() => expect(triggerProbeMock).toHaveBeenCalled());
499566
expect(capturedSignal).toBeDefined();
@@ -550,7 +617,7 @@ describe("useTriggerProbe", () => {
550617
},
551618
);
552619
const { result } = renderHook(() => useTriggerProbe({ token: "t" }));
553-
let firstPromise: Promise<TriggerResponse> | null = null;
620+
let firstPromise: Promise<TriggerResponse | null> | null = null;
554621
act(() => {
555622
firstPromise = result.current.trigger("smoke");
556623
// Swallow potential rejection so unhandled-rejection guards don't
@@ -560,7 +627,7 @@ describe("useTriggerProbe", () => {
560627
await waitFor(() => expect(triggerProbeMock).toHaveBeenCalledTimes(1));
561628

562629
// Fire second call — this aborts the first.
563-
let secondPromise: Promise<TriggerResponse> | null = null;
630+
let secondPromise: Promise<TriggerResponse | null> | null = null;
564631
act(() => {
565632
secondPromise = result.current.trigger("smoke");
566633
});
@@ -570,24 +637,89 @@ describe("useTriggerProbe", () => {
570637

571638
// The first promise must NOT reject with AbortError surfaced — the
572639
// hook should swallow it. We resolve via the supersession path.
573-
let firstResult: unknown = "pending";
640+
let firstResult: TriggerResponse | null | undefined = undefined;
574641
let firstError: unknown = null;
575-
firstPromise!
642+
await firstPromise!
576643
.then((v) => {
577644
firstResult = v;
578645
})
579646
.catch((e) => {
580647
firstError = e;
581648
});
582-
// Yield enough for any pending settlements.
583-
await Promise.resolve();
584-
await Promise.resolve();
585-
// Per spec: first promise resolves silently (returns undefined) when
649+
// Per spec: first promise resolves silently (returns null) when
586650
// superseded; AbortError must NOT be thrown to the caller.
587651
expect((firstError as { name?: string })?.name).not.toBe("AbortError");
588-
void firstResult;
652+
// R3-C.1: supersession resolves to null (typed), not undefined-cast.
653+
expect(firstResult).toBeNull();
589654

590655
// Error state must remain null.
591656
expect(result.current.error).toBeNull();
592657
});
658+
659+
it("supersession path resolves to null and is discriminable (R3-C.1)", async () => {
660+
// Caller-facing contract: a superseded trigger resolves to `null`, never
661+
// to a fake TriggerResponse. The type system reflects this so callers
662+
// can `if (r === null)` to detect supersession.
663+
triggerProbeMock.mockImplementationOnce(
664+
(_id: string, opts: { signal?: AbortSignal }) =>
665+
new Promise<TriggerResponse>((_resolve, reject) => {
666+
opts.signal?.addEventListener("abort", () => {
667+
reject(new DOMException("aborted", "AbortError"));
668+
});
669+
}),
670+
);
671+
triggerProbeMock.mockImplementationOnce(() =>
672+
Promise.resolve(triggerOk()),
673+
);
674+
const { result } = renderHook(() => useTriggerProbe({ token: "t" }));
675+
676+
let firstPromise!: Promise<TriggerResponse | null>;
677+
act(() => {
678+
firstPromise = result.current.trigger("smoke");
679+
firstPromise.catch(() => {});
680+
});
681+
await waitFor(() => expect(triggerProbeMock).toHaveBeenCalledTimes(1));
682+
683+
// Supersede with a second call.
684+
let secondPromise!: Promise<TriggerResponse | null>;
685+
act(() => {
686+
secondPromise = result.current.trigger("smoke");
687+
});
688+
const secondValue = await act(async () => secondPromise);
689+
690+
// Second call returns a real response.
691+
expect(secondValue).not.toBeNull();
692+
expect(secondValue?.runId).toBe("run-1");
693+
694+
// First call resolves to null (the discriminator).
695+
const firstValue = await firstPromise;
696+
expect(firstValue).toBeNull();
697+
// Discriminate via strict null check.
698+
expect(firstValue === null).toBe(true);
699+
});
700+
701+
it("clears error on next successful trigger (R3-C.4)", async () => {
702+
// First call fails → error set. Second call succeeds → error cleared.
703+
triggerProbeMock.mockRejectedValueOnce(new Error("forbidden"));
704+
triggerProbeMock.mockResolvedValueOnce(triggerOk());
705+
706+
const { result } = renderHook(() => useTriggerProbe({ token: "t" }));
707+
708+
await act(async () => {
709+
try {
710+
await result.current.trigger("smoke");
711+
} catch {
712+
// expected
713+
}
714+
});
715+
await waitFor(() =>
716+
expect(result.current.error?.message).toBe("forbidden"),
717+
);
718+
719+
await act(async () => {
720+
await result.current.trigger("smoke");
721+
});
722+
// Error must be cleared by the successful trigger.
723+
await waitFor(() => expect(result.current.error).toBeNull());
724+
});
593725
});

showcase/shell-dashboard/src/hooks/use-probes.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export function useProbes(opts?: {
9292
// Surface a "refreshing" indicator across dep changes (baseUrl swap)
9393
// so consumers can render a loading state instead of stale data.
9494
setLoading(true);
95+
// R3-C.3: clear any error from the prior dep tuple (e.g. previous
96+
// baseUrl). Otherwise an error from a stale baseUrl persists under the
97+
// new one until a fresh failure or successful fetch overwrites it.
98+
setError(null);
9599
void run();
96100
const timer = setInterval(() => {
97101
void run();
@@ -154,6 +158,11 @@ export function useProbeDetail(
154158
// the panel header (driven by the prop) is never out of sync with the
155159
// data body. The new fetch will repopulate this on resolution.
156160
setData(null);
161+
// R3-C.2: also clear any error from the prior probe so the detail panel
162+
// doesn't render a stale error message under the new probe header. Set
163+
// before flipping `loading` so the UI never shows error+loading at once
164+
// for the new id.
165+
setError(null);
157166
setLoading(true);
158167

159168
async function run(): Promise<void> {
@@ -200,7 +209,18 @@ export function useProbeDetail(
200209
// ─────────────────────────────────────────────────────────────────────────
201210

202211
export interface UseTriggerProbeResult {
203-
trigger: (probeId: string, slugs?: string[]) => Promise<TriggerResponse>;
212+
/**
213+
* Trigger a probe run.
214+
*
215+
* Resolves to a `TriggerResponse` on success. Resolves to `null` when the
216+
* call is superseded by a back-to-back trigger (the AbortError is swallowed
217+
* — see R2-C.2). Callers that care about the response must null-check.
218+
* Throws on real failures (missing token, server error, etc.).
219+
*/
220+
trigger: (
221+
probeId: string,
222+
slugs?: string[],
223+
) => Promise<TriggerResponse | null>;
204224
pending: boolean;
205225
error: Error | null;
206226
}
@@ -235,7 +255,10 @@ export function useTriggerProbe(opts?: {
235255
}, []);
236256

237257
const trigger = useCallback(
238-
async (probeId: string, slugs?: string[]): Promise<TriggerResponse> => {
258+
async (
259+
probeId: string,
260+
slugs?: string[],
261+
): Promise<TriggerResponse | null> => {
239262
if (!token) {
240263
const err = new Error(
241264
"useTriggerProbe: token is required to trigger a probe run",
@@ -245,6 +268,8 @@ export function useTriggerProbe(opts?: {
245268
}
246269
if (aliveRef.current) {
247270
setPending(true);
271+
// R3-C.4: clear any prior trigger error eagerly so a successful
272+
// call doesn't leave a stale error hanging on the result object.
248273
setError(null);
249274
}
250275
// Replace any prior in-flight controller — back-to-back triggers
@@ -268,10 +293,11 @@ export function useTriggerProbe(opts?: {
268293
(err as { name?: string })?.name === "AbortError" ||
269294
controller.signal.aborted;
270295
if (isAbort) {
271-
// Caller's promise resolves with `undefined as TriggerResponse`.
272-
// Real callers either await the new trigger or ignore the old
273-
// promise; none should consume undefined as a TriggerResponse.
274-
return undefined as unknown as TriggerResponse;
296+
// R3-C.1: resolve with `null` (typed in the signature) so callers
297+
// can discriminate supersession without a type-system lie. The
298+
// previous `undefined as unknown as TriggerResponse` violated the
299+
// declared contract.
300+
return null;
275301
}
276302
const e = err instanceof Error ? err : new Error(String(err));
277303
if (aliveRef.current) setError(e);

showcase/shell-dashboard/src/lib/ops-api.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,12 @@ async function parseJson<T>(response: Response, url: string): Promise<T> {
173173
throw parseErr;
174174
}
175175
const msg = (parseErr as Error)?.message ?? "unknown";
176-
throw new Error(`ops-api JSON parse failed at ${url}: ${msg}`);
176+
// R3-C bonus: preserve the original error as `cause` so debuggers can
177+
// walk back to the SyntaxError name/stack without re-parsing the
178+
// wrapped message string.
179+
throw new Error(`ops-api JSON parse failed at ${url}: ${msg}`, {
180+
cause: parseErr,
181+
});
177182
}
178183
}
179184

0 commit comments

Comments
 (0)