From 4ac10f11c53f752547e303633578db73135f5647 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:07:14 -0700 Subject: [PATCH 1/3] CL-6834: Add tests for provider health poll failure --- .../src/shell/provider-health-context.test.ts | 62 ++++++++++++++++++- apps/web/test/provider-health-banner.test.tsx | 50 ++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/apps/web/src/shell/provider-health-context.test.ts b/apps/web/src/shell/provider-health-context.test.ts index cd472e0d0..180f8c685 100644 --- a/apps/web/src/shell/provider-health-context.test.ts +++ b/apps/web/src/shell/provider-health-context.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { deriveProviderHealthBanner } from "./provider-health-context"; +import { + deriveProviderHealthBanner, + deriveProviderHealthChrome, + nextProviderHealthPollStatus, +} from "./provider-health-context"; const RECORD = { status: "needs_attention" as const, @@ -77,3 +81,59 @@ describe("deriveProviderHealthBanner", () => { ); }); }); + +describe("nextProviderHealthPollStatus (CL-6834)", () => { + test("a successful poll always lands on ready", () => { + expect(nextProviderHealthPollStatus("unknown", "ok")).toBe("ready"); + expect(nextProviderHealthPollStatus("error", "ok")).toBe("ready"); + expect(nextProviderHealthPollStatus("ready", "ok")).toBe("ready"); + }); + + test("first-load failure (unknown → fail) becomes error, not ready", () => { + expect(nextProviderHealthPollStatus("unknown", "fail")).toBe("error"); + }); + + test("a failed poll after an error stays error until a success", () => { + expect(nextProviderHealthPollStatus("error", "fail")).toBe("error"); + }); + + test("a failed poll after ready keeps ready so last-known state stays on screen", () => { + expect(nextProviderHealthPollStatus("ready", "fail")).toBe("ready"); + }); +}); + +describe("deriveProviderHealthChrome (CL-6834)", () => { + const unhealthyBanner = { + provider: "anthropic", + category: "credential_failure" as const, + zeroWorkingProviders: false, + }; + + test("unknown status is never healthy — empty providers are not 'all clear'", () => { + expect(deriveProviderHealthChrome("unknown", null)).toEqual({ + kind: "unknown", + }); + expect(deriveProviderHealthChrome("unknown", unhealthyBanner)).toEqual({ + kind: "unknown", + }); + }); + + test("error status is never healthy — first-load poll failure is not 'all clear'", () => { + expect(deriveProviderHealthChrome("error", null)).toEqual({ + kind: "error", + }); + }); + + test("ready with no banner is healthy", () => { + expect(deriveProviderHealthChrome("ready", null)).toEqual({ + kind: "healthy", + }); + }); + + test("ready with an undismissed incident is unhealthy", () => { + expect(deriveProviderHealthChrome("ready", unhealthyBanner)).toEqual({ + kind: "unhealthy", + banner: unhealthyBanner, + }); + }); +}); diff --git a/apps/web/test/provider-health-banner.test.tsx b/apps/web/test/provider-health-banner.test.tsx index cfab23d27..5ba31add4 100644 --- a/apps/web/test/provider-health-banner.test.tsx +++ b/apps/web/test/provider-health-banner.test.tsx @@ -101,6 +101,22 @@ async function flush(): Promise { } } +/** Same flush loop, but waits for a specific `data-provider-health` marker + * (CL-6834) — used when the chrome settles without an alert (healthy) or + * with an error marker rather than an unhealthy-provider alert. */ +async function flushForHealthMarker(marker: string): Promise { + for (let i = 0; i < 20; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if ( + document.querySelector(`[data-provider-health="${marker}"]`) !== null + ) { + return; + } + } +} + function findByText( container: HTMLElement, text: string, @@ -247,9 +263,41 @@ describe("ProviderHealthBanner (CL-6092)", () => { test("renders nothing when no provider is unhealthy", async () => { stubFetch({ providers: {}, connectedProviderCount: 2 }); await mount(() => undefined); - await flush(); + await flushForHealthMarker("healthy"); expect(container.querySelector('[role="alert"]')).toBeNull(); + expect( + container.querySelector('[data-provider-health="healthy"]'), + ).not.toBeNull(); + }); + + // CL-6834: a failed first poll used to leave providers at {}, which the + // chrome treated the same as "ready and nothing unhealthy" — so an + // unreachable health endpoint looked like every provider was fine. + test("first-load poll failure shows error chrome, not a silent healthy state", async () => { + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/api/me/principals")) + return Promise.resolve(json(membership)); + if (path.includes("/connections/provider-health")) + return Promise.reject(new Error("network down")); + return Promise.resolve(json({ items: [] })); + }) as typeof fetch; + + await mount(() => undefined); + await flushForHealthMarker("error"); + + expect( + container.querySelector('[data-provider-health="error"]'), + ).not.toBeNull(); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + expect(container.textContent).toContain("Couldn't check provider health"); + // Not the guided unhealthy-provider copy — there is no incident to fix. + expect(container.textContent).not.toContain("turned down your key."); + expect(findByText(container, "Fix it")).toBeUndefined(); + expect( + container.querySelector('[data-provider-health="healthy"]'), + ).toBeNull(); }); }); From 1691b2bed924e11fcd2b551005ad019eb2a61109 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 07:07:14 -0700 Subject: [PATCH 2/3] CL-6834: Do not treat provider health poll failure as healthy --- apps/web/src/shell/provider-health-banner.tsx | 102 +++++++++++++----- .../web/src/shell/provider-health-context.tsx | 101 +++++++++++++++-- 2 files changed, 169 insertions(+), 34 deletions(-) diff --git a/apps/web/src/shell/provider-health-banner.tsx b/apps/web/src/shell/provider-health-banner.tsx index d8d43e6fb..a1aa7085b 100644 --- a/apps/web/src/shell/provider-health-banner.tsx +++ b/apps/web/src/shell/provider-health-banner.tsx @@ -8,6 +8,10 @@ // consumption) — or, when the tenant has zero working providers, // routes to onboarding's credential step instead, since there is no // provider gallery worth opening yet. +// +// Chrome kinds (CL-6834): `unknown` (still polling), `error` (first-load +// poll failed — not the same silence as healthy), `healthy` (ready and +// nothing unhealthy), and `unhealthy` (guided Fix-it banner). import { Button } from "@corbits/react-ui"; import { connectorDescriptors } from "@workbench/connections/registry"; @@ -20,9 +24,10 @@ import { matchesRoute, ONBOARDING_PATH, SETTINGS_PATH } from "../routes"; import { WORKBENCH_PATH_PREFIX } from "../workbench-path"; import { useDismissProviderHealthBanner, - useProviderHealthBanner, + useProviderHealthChrome, useRequestPluginsConnect, type ProviderHealthBannerState, + type ProviderHealthChrome, } from "./provider-health-context"; const PLUGINS_PATH = "/plugins"; @@ -42,6 +47,8 @@ export function isProviderHealthRecoverySurface(path: string): boolean { // magic number repeated in two files. const COLLAPSE_TRANSITION_MS = 220; +const POLL_ERROR_COPY = "Couldn't check provider health. Try again shortly."; + // The connector registry's own `displayName` (`@workbench/connections/registry`, // the same browser-safe subpath `plugins.ts` already reads) — never a // reinvented title-case of the provider id, which mangles ids like @@ -69,73 +76,114 @@ function bannerMessage(banner: ProviderHealthBannerState): string { return `${providerDisplayName(banner.provider)} ${CATEGORY_COPY[banner.category]}`; } +type VisibleChrome = + | { readonly kind: "error" } + | { readonly kind: "unhealthy"; readonly banner: ProviderHealthBannerState }; + +function visibleFromChrome(chrome: ProviderHealthChrome): VisibleChrome | null { + if (chrome.kind === "error") return { kind: "error" }; + if (chrome.kind === "unhealthy") return chrome; + return null; +} + export function ProviderHealthBanner({ path }: { readonly path: string }) { - const banner = useProviderHealthBanner(); + const chrome = useProviderHealthChrome(); const dismiss = useDismissProviderHealthBanner(); const requestPluginsConnect = useRequestPluginsConnect(); const navigate = useNavigate(); + const visible = visibleFromChrome(chrome); + // Stable deps for the cache effect — `visible` is a fresh object each + // render, so depending on it would loop (setState → re-render → new + // object → effect again). + const visibleKind = visible?.kind ?? null; + const unhealthyBanner = + chrome.kind === "unhealthy" ? chrome.banner : null; + // Keeps the banner's last-known content mounted for a moment after - // `banner` goes null, so the collapse/fade-out transition below has + // `visible` goes null, so the collapse/fade-out transition below has // something to animate away rather than the stage snapping shut // instantly — see the module comment on hard-shoving the stage this - // replaces. The `role="alert"` below still comes off the LIVE `banner`, + // replaces. The `role="alert"` below still comes off the LIVE chrome, // not this cache, so a screen reader never sees a stale alert linger. - const [cachedBanner, setCachedBanner] = - useState(banner); + const [cachedVisible, setCachedVisible] = useState( + visible, + ); useEffect(() => { - if (banner !== null) { - setCachedBanner(banner); + if (visibleKind === "error") { + setCachedVisible({ kind: "error" }); + return; + } + if (visibleKind === "unhealthy" && unhealthyBanner !== null) { + setCachedVisible({ kind: "unhealthy", banner: unhealthyBanner }); return; } const timeout = setTimeout( - () => setCachedBanner(null), + () => setCachedVisible(null), COLLAPSE_TRANSITION_MS, ); return () => clearTimeout(timeout); - }, [banner]); + }, [visibleKind, unhealthyBanner]); - const isOpen = banner !== null; + const isOpen = visible !== null; if (!isProviderHealthRecoverySurface(path)) { return null; } const handleFix = () => { - if (banner === null) return; - if (banner.zeroWorkingProviders) { + if (chrome.kind !== "unhealthy") return; + if (chrome.banner.zeroWorkingProviders) { navigate(ONBOARDING_PATH); return; } - requestPluginsConnect(banner.provider); + requestPluginsConnect(chrome.banner.provider); navigate(PLUGINS_PATH); }; + // A ready all-clear still mounts a zero-size marker so tests (and any + // future chrome) can tell "actually healthy" from "unknown / not yet + // polled" without treating empty DOM as healthy (CL-6834). + if (chrome.kind === "healthy" && cachedVisible === null) { + return