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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-21 14:45:28 -04:00
parent cc8da01d37
commit bafec13ed1
2 changed files with 335 additions and 71 deletions

View File

@@ -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();
});

View File

@@ -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 (
<div className="flex items-center gap-3 px-4 py-4" data-testid={testId}>
<p className="min-w-0 flex-1 text-sm text-destructive">{message}</p>
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
</Button>
</div>
);
}
// ── 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."
>
<div className="space-y-3 px-4 py-4">
<div className="flex items-end gap-2">
<div className="min-w-0">
<label
className="mb-1.5 block text-sm font-medium"
htmlFor="local-archive-retention-days"
{status === "error" ? (
<LoadErrorRow
message="Couldn't load the retention window."
onRetry={onRetry}
testId="local-archive-retention-error-state"
/>
) : (
<div className="space-y-3 px-4 py-4">
<div className="flex items-end gap-2">
<div className="min-w-0">
<label
className="mb-1.5 block text-sm font-medium"
htmlFor="local-archive-retention-days"
>
Keep for (days)
</label>
<input
aria-describedby={
showError ? "local-archive-retention-error" : undefined
}
aria-invalid={showError}
className="w-32 rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="local-archive-retention-days"
disabled={savedDays === null}
id="local-archive-retention-days"
inputMode="numeric"
onChange={(e) => setDraft(e.target.value)}
placeholder="30"
type="text"
value={draft}
/>
</div>
<Button
data-testid="local-archive-retention-save"
disabled={!canSave}
onClick={() => void handleSave()}
type="button"
>
Keep for (days)
</label>
<input
className="w-32 rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="local-archive-retention-days"
disabled={savedDays === null}
id="local-archive-retention-days"
inputMode="numeric"
onChange={(e) => setDraft(e.target.value)}
placeholder="30"
type="text"
value={draft}
/>
{isSaving ? "Saving…" : "Save"}
</Button>
</div>
<Button
data-testid="local-archive-retention-save"
disabled={!canSave}
onClick={() => void handleSave()}
type="button"
>
{isSaving ? "Saving…" : "Save"}
</Button>
{showError && (
<p
className="text-xs text-destructive"
data-testid="local-archive-retention-error"
id="local-archive-retention-error"
>
{parsed.error}
</p>
)}
</div>
{invalid && draft.trim() !== "" && (
<p
className="text-xs text-destructive"
data-testid="local-archive-retention-error"
>
{parsed.error}
</p>
)}
</div>
)}
</SettingsOptionGroup>
</div>
);
@@ -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 (
<div data-testid="local-archive-size-section">
<SettingsOptionGroup title="Storage">
<SettingsOptionRow>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Archive size on disk</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
{status === "error" ? (
<LoadErrorRow
message="Couldn't load the archive size."
onRetry={onRetry}
testId="local-archive-size-error-state"
/>
) : (
<SettingsOptionRow>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Archive size on disk</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
{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.`}
</p>
</div>
<span
className="shrink-0 text-sm font-medium tabular-nums"
data-testid="local-archive-size-physical"
>
{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.`}
</p>
</div>
<span
className="shrink-0 text-sm font-medium tabular-nums"
data-testid="local-archive-size-physical"
>
{readout?.physicalLabel ?? "—"}
</span>
</SettingsOptionRow>
{readout?.physicalLabel ?? "—"}
</span>
</SettingsOptionRow>
)}
</SettingsOptionGroup>
</div>
);
@@ -531,9 +586,12 @@ export function LocalArchiveSettingsCard() {
const [observerToggling, setObserverToggling] = React.useState(false);
const [metricToggling, setMetricToggling] = React.useState(false);
const [retentionDays, setRetentionDays] = React.useState<number | null>(null);
const [retentionStatus, setRetentionStatus] =
React.useState<LoadStatus>("loading");
const [sizeStats, setSizeStats] = React.useState<ArchiveSizeStats | null>(
null,
);
const [sizeStatus, setSizeStatus] = React.useState<LoadStatus>("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 */}
<ObserverRetentionSection
onRetry={loadRetention}
onSaved={setRetentionDays}
savedDays={retentionDays}
status={retentionStatus}
/>
{/* Archive size readout */}
<ArchiveSizeSection stats={sizeStats} />
<ArchiveSizeSection
onRetry={loadSize}
stats={sizeStats}
status={sizeStatus}
/>
{/* Channel subscriptions */}
<div data-testid="local-archive-subscriptions">