mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user