From bafec13ed160a34a87af88457fda778128ce5007 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 14:45:28 -0400 Subject: [PATCH] fix(desktop): add load-failure retry and a11y to archive retention card The retention window and size readout only warned on a failed initial read, leaving each section stuck in its loading state with no visible failure or recovery. Track a per-section load status and render an inline error row with a Retry action so a failed read never wedges the card. Associate the retention input's inline validation message with the field via aria-invalid and aria-describedby. Add a full-card mocked get -> set -> get round-trip test covering the persisted read on mount, the set invoke, and the state advance that re-disables Save. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../ui/LocalArchiveSettingsCard.test.mjs | 188 ++++++++++++++- .../ui/LocalArchiveSettingsCard.tsx | 218 ++++++++++++------ 2 files changed, 335 insertions(+), 71 deletions(-) diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs index 58069ade55..c7a3992cf1 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs @@ -29,12 +29,17 @@ let screen; let createElement; let ObserverRetentionSection; let ArchiveSizeSection; +let LocalArchiveSettingsCard; +let QueryClient; +let QueryClientProvider; +let CommunitiesProvider; before(async () => { Object.assign(globalThis, { document: dom.window.document, HTMLElement: dom.window.HTMLElement, window: dom.window, + localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true, }); Object.defineProperty(globalThis, "navigator", { @@ -60,8 +65,13 @@ before(async () => { "@testing-library/react" )); ({ createElement } = await import("react")); - ({ ObserverRetentionSection, ArchiveSizeSection } = await import( - "./LocalArchiveSettingsCard.tsx" + ({ ObserverRetentionSection, ArchiveSizeSection, LocalArchiveSettingsCard } = + await import("./LocalArchiveSettingsCard.tsx")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ CommunitiesProvider } = await import( + "@/features/communities/useCommunities.tsx" )); }); @@ -79,6 +89,8 @@ test("retention field seeds from the saved value and disables Save while unchang render( createElement(ObserverRetentionSection, { savedDays: 30, + status: "loaded", + onRetry: () => {}, onSaved: () => {}, }), ); @@ -95,6 +107,8 @@ test("input disabled until the saved value loads", async () => { render( createElement(ObserverRetentionSection, { savedDays: null, + status: "loading", + onRetry: () => {}, onSaved: () => {}, }), ); @@ -111,6 +125,8 @@ test("out-of-bounds input surfaces an inline error and blocks Save", async () => render( createElement(ObserverRetentionSection, { savedDays: 30, + status: "loaded", + onRetry: () => {}, onSaved: () => {}, }), ); @@ -131,6 +147,60 @@ test("out-of-bounds input surfaces an inline error and blocks Save", async () => ); }); +test("inline validation error is associated with the input via aria", async () => { + await act(async () => { + render( + createElement(ObserverRetentionSection, { + savedDays: 30, + status: "loaded", + onRetry: () => {}, + onSaved: () => {}, + }), + ); + }); + + const input = screen.getByTestId("local-archive-retention-days"); + + // Valid state: no error wiring. + assert.equal(input.getAttribute("aria-invalid"), "false"); + assert.equal(input.getAttribute("aria-describedby"), null); + + await act(async () => { + fireEvent.change(input, { target: { value: "0" } }); + }); + + const error = screen.getByTestId("local-archive-retention-error"); + assert.equal(input.getAttribute("aria-invalid"), "true"); + assert.equal(input.getAttribute("aria-describedby"), error.id); + assert.ok(error.id, "error message carries an id to reference"); +}); + +test("retention load failure shows an error state with retry, not a stuck field", async () => { + let retries = 0; + await act(async () => { + render( + createElement(ObserverRetentionSection, { + savedDays: null, + status: "error", + onRetry: () => { + retries += 1; + }, + onSaved: () => {}, + }), + ); + }); + + // The input/Save affordance is replaced by an explicit error+retry row. + assert.equal(screen.queryByTestId("local-archive-retention-days"), null); + const errorState = screen.getByTestId("local-archive-retention-error-state"); + assert.match(errorState.textContent, /Couldn't load/); + + await act(async () => { + fireEvent.click(errorState.querySelector("button")); + }); + assert.equal(retries, 1, "Retry invokes the reload callback"); +}); + test("a valid change saves through the invoke and reports the new value", async () => { const setCalls = []; ipcHandlers.set("set_observer_retention_days", (args) => { @@ -143,6 +213,8 @@ test("a valid change saves through the invoke and reports the new value", async render( createElement(ObserverRetentionSection, { savedDays: 30, + status: "loaded", + onRetry: () => {}, onSaved: (d) => { saved = d; }, @@ -172,6 +244,8 @@ test("size readout shows physical size and reclaimable bytes", async () => { await act(async () => { render( createElement(ArchiveSizeSection, { + status: "loaded", + onRetry: () => {}, stats: { mainFileBytes: 1024 ** 3, walFileBytes: 512 * 1024 ** 2, @@ -195,7 +269,13 @@ test("size readout shows physical size and reclaimable bytes", async () => { test("size readout shows a loading fallback before stats arrive", async () => { await act(async () => { - render(createElement(ArchiveSizeSection, { stats: null })); + render( + createElement(ArchiveSizeSection, { + stats: null, + status: "loading", + onRetry: () => {}, + }), + ); }); assert.equal( @@ -207,3 +287,105 @@ test("size readout shows a loading fallback before stats arrive", async () => { /Loading…/, ); }); + +test("size load failure shows an error state with retry", async () => { + let retries = 0; + await act(async () => { + render( + createElement(ArchiveSizeSection, { + stats: null, + status: "error", + onRetry: () => { + retries += 1; + }, + }), + ); + }); + + assert.equal(screen.queryByTestId("local-archive-size-physical"), null); + const errorState = screen.getByTestId("local-archive-size-error-state"); + assert.match(errorState.textContent, /Couldn't load/); + + await act(async () => { + fireEvent.click(errorState.querySelector("button")); + }); + assert.equal(retries, 1, "Retry invokes the reload callback"); +}); + +// ── Full-card get → set → get round trip ────────────────────────────────────── + +test("full card loads the persisted retention, saves an edit, and reflects it", async () => { + // No stored communities ⇒ activeCommunity is null ⇒ the channels query stays + // disabled, so only these archive commands fire. get_observer_retention_days + // reads the store on mount; set persists; the card advances its own state + // from the resolved set (no re-fetch), which is the get→set→get contract. + let stored = 30; + const setCalls = []; + ipcHandlers.set("get_identity", () => + Promise.resolve({ pubkey: "f".repeat(64), display_name: "Tester" }), + ); + ipcHandlers.set("list_save_subscriptions", () => Promise.resolve([])); + ipcHandlers.set("get_observer_retention_days", () => Promise.resolve(stored)); + ipcHandlers.set("archive_size_stats", () => + Promise.resolve({ + mainFileBytes: 1024 ** 3, + walFileBytes: 0, + pageSize: 4096, + pageCount: 262_144, + freelistCount: 0, + }), + ); + ipcHandlers.set("set_observer_retention_days", (args) => { + setCalls.push(args); + stored = args.days; + return Promise.resolve(null); + }); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + + await act(async () => { + render( + createElement( + QueryClientProvider, + { client }, + createElement( + CommunitiesProvider, + null, + createElement(LocalArchiveSettingsCard), + ), + ), + ); + }); + + // get: field seeded from the persisted 30, Save disabled while unchanged. + const input = screen.getByTestId("local-archive-retention-days"); + assert.equal(input.value, "30"); + assert.equal( + screen.getByTestId("local-archive-retention-save").disabled, + true, + ); + + // set: edit to a new valid value and save. + await act(async () => { + fireEvent.change(input, { target: { value: "7" } }); + }); + await act(async () => { + fireEvent.click(screen.getByTestId("local-archive-retention-save")); + }); + + assert.deepEqual(setCalls, [{ days: 7 }], "set invoke receives parsed days"); + assert.equal(stored, 7, "backing store now holds the new value"); + + // get (post-set): the card advanced its saved value, so the field still shows + // 7 and Save has returned to disabled — the persisted value round-trips. + assert.equal(input.value, "7"); + assert.equal( + screen.getByTestId("local-archive-retention-save").disabled, + true, + "Save re-disables once the edit matches the new saved value", + ); + + client.clear(); +}); diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index 3854df57bf..e2ebb84038 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -67,6 +67,30 @@ function kindSummary(kinds: number[]): string { return `${kinds.slice(0, 3).join(", ")} +${kinds.length - 3} more`; } +/** Load lifecycle for the two independently-fetched sections below. */ +export type LoadStatus = "loading" | "error" | "loaded"; + +/** Inline failure row with a retry action, scoped to a single section so a + * failed read never wedges the rest of the card. */ +function LoadErrorRow({ + message, + onRetry, + testId, +}: { + message: string; + onRetry: () => void; + testId: string; +}) { + return ( +
+

{message}

+ +
+ ); +} + // ── Observer-feed archive section ───────────────────────────────────────────── type ObserverSectionProps = { @@ -161,13 +185,18 @@ function AgentMetricArchiveSection({ // ── Observer retention section ──────────────────────────────────────────────── type RetentionSectionProps = { - /** Persisted value, or `null` while the initial load is in flight. */ + /** Persisted value, or `null` while unavailable (loading or errored). */ savedDays: number | null; + /** Load lifecycle; drives the error/retry affordance. */ + status: LoadStatus; + onRetry: () => void; onSaved: (days: number) => void; }; export function ObserverRetentionSection({ savedDays, + status, + onRetry, onSaved, }: RetentionSectionProps) { const [draft, setDraft] = React.useState(""); @@ -181,6 +210,7 @@ export function ObserverRetentionSection({ const parsed = parseRetentionDays(draft); const invalid = !parsed.ok; + const showError = invalid && draft.trim() !== ""; const unchanged = parsed.ok && savedDays !== null && parsed.days === savedDays; const canSave = parsed.ok && !unchanged && !isSaving; @@ -207,45 +237,58 @@ export function ObserverRetentionSection({ title="Observer frame retention" description="Observer frames older than this many days are pruned from the local archive. NIP-AM turn metrics and every other archived kind are kept forever." > -
-
-
-
+ )}
); @@ -255,32 +298,44 @@ export function ObserverRetentionSection({ export function ArchiveSizeSection({ stats, + status, + onRetry, }: { stats: ArchiveSizeStats | null; + status: LoadStatus; + onRetry: () => void; }) { const readout = stats ? deriveSizeReadout(stats) : null; return (
- -
-

Archive size on disk

-

+ ) : ( + +

+

Archive size on disk

+

+ {readout === null + ? "Loading…" + : `Physical size ${readout.physicalLabel} (database + write-ahead log). ${readout.reclaimableLabel} is reclaimable after pruning — freed pages become reusable space; the file only shrinks after an offline compaction.`} +

+
+ - {readout === null - ? "Loading…" - : `Physical size ${readout.physicalLabel} (database + write-ahead log). ${readout.reclaimableLabel} is reclaimable after pruning — freed pages become reusable space; the file only shrinks after an offline compaction.`} -

-
- - {readout?.physicalLabel ?? "—"} - -
+ {readout?.physicalLabel ?? "—"} + + + )}
); @@ -531,9 +586,12 @@ export function LocalArchiveSettingsCard() { const [observerToggling, setObserverToggling] = React.useState(false); const [metricToggling, setMetricToggling] = React.useState(false); const [retentionDays, setRetentionDays] = React.useState(null); + const [retentionStatus, setRetentionStatus] = + React.useState("loading"); const [sizeStats, setSizeStats] = React.useState( null, ); + const [sizeStatus, setSizeStatus] = React.useState("loading"); const pubkey = identityQuery.data?.pubkey ?? ""; @@ -566,21 +624,39 @@ export function LocalArchiveSettingsCard() { }, [reload]); // Retention window + size readout load independently of the subscription - // list; a failure in either is non-fatal (the sections show their loading - // fallback) and must not blank the rest of the card. - React.useEffect(() => { + // list; a failure in either is non-fatal — the affected section shows an + // inline error with a retry action and never blanks the rest of the card. + const loadRetention = React.useCallback(() => { + setRetentionStatus("loading"); void getObserverRetentionDays() - .then(setRetentionDays) - .catch((err) => - console.warn("[LocalArchiveSettingsCard] retention load failed:", err), - ); - void archiveSizeStats() - .then(setSizeStats) - .catch((err) => - console.warn("[LocalArchiveSettingsCard] size load failed:", err), - ); + .then((days) => { + setRetentionDays(days); + setRetentionStatus("loaded"); + }) + .catch((err) => { + console.warn("[LocalArchiveSettingsCard] retention load failed:", err); + setRetentionStatus("error"); + }); }, []); + const loadSize = React.useCallback(() => { + setSizeStatus("loading"); + void archiveSizeStats() + .then((stats) => { + setSizeStats(stats); + setSizeStatus("loaded"); + }) + .catch((err) => { + console.warn("[LocalArchiveSettingsCard] size load failed:", err); + setSizeStatus("error"); + }); + }, []); + + React.useEffect(() => { + loadRetention(); + loadSize(); + }, [loadRetention, loadSize]); + const handleDelete = React.useCallback( async (scopeType: ScopeType, scopeValue: string) => { const key = `${scopeType}:${scopeValue}`; @@ -698,12 +774,18 @@ export function LocalArchiveSettingsCard() { {/* Observer frame retention window */} {/* Archive size readout */} - + {/* Channel subscriptions */}