Files
hermes-agent/web/src/components/MemoryPressureBanner.test.tsx
T
Shannon Sands 6977d21fa7 feat(gateway): disk-usage telemetry + dashboard disk-pressure banner (NS-656)
Extends the NS-656 memory-pressure surface to cover disk exhaustion
(OOF-2 / OOF-107 lineage: agents fill their data volume — SQLite writes
fail, sessions stop persisting — while every dashboard looks healthy).

- gateway/disk_status.py (new): collect_disk_status() samples
  shutil.disk_usage(HERMES_HOME) and classifies pressure
  (critical: <256 MB free or >=95% used; elevated: <512 MB free).
  Never raises — degrades to pressure="unknown" with null telemetry,
  same contract as collect_memory_status().
- /api/status: sibling `disk` block next to `memory`, advisory only —
  not folded into component/overall health.
- web: DiskPressureStatus type; MemoryPressureBanner generalized to a
  resource banner with worst-first triggers (disk critical > memory
  critical > OOM restart > disk elevated > memory elevated) and
  cascading dismissals — hiding the top trigger surfaces the next one
  instead of silencing everything. All dismissals stay boot_id-scoped.
- i18n: diskCriticalBanner / diskElevatedBanner (en, optional fields
  with English fallback per existing pattern).

Tests: gateway/test_disk_status.py (14), web_server disk-block
presence/degradation, banner disk trigger/priority/dismissal-cascade
suite (21 total).
2026-08-13 20:30:12 -07:00

439 lines
14 KiB
TypeScript

// @vitest-environment jsdom
// Tests for the NS-656 memory-pressure banner: trigger selection,
// severity precedence, dismissal semantics, and escalation re-opening.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ReactNode } from "react";
import { I18nProvider } from "@/i18n";
import { MemoryPressureBanner } from "./MemoryPressureBanner";
import type {
StatusResponse,
MemoryPressureStatus,
DiskPressureStatus,
} from "@/lib/api";
let container: HTMLDivElement;
let root: Root;
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(<I18nProvider>{ui}</I18nProvider>));
}
async function rerender(ui: ReactNode) {
await act(async () => root.render(<I18nProvider>{ui}</I18nProvider>));
}
beforeEach(() => {
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
});
function statusWith(memory: MemoryPressureStatus | undefined): StatusResponse {
return { memory } as StatusResponse;
}
function statusWithDisk(
disk: DiskPressureStatus | undefined,
memory?: MemoryPressureStatus,
): StatusResponse {
return { memory, disk } as StatusResponse;
}
function banner(): HTMLElement | null {
return container.querySelector('[data-testid="memory-pressure-banner"]');
}
describe("MemoryPressureBanner", () => {
it("renders nothing for null status or healthy memory", async () => {
await render(<MemoryPressureBanner status={null} />);
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "ok" })} />,
);
expect(banner()).toBeNull();
// Older gateways: no memory block at all.
await rerender(<MemoryPressureBanner status={statusWith(undefined)} />);
expect(banner()).toBeNull();
});
it("renders nothing for unknown pressure (absence of evidence)", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "unknown" })} />,
);
expect(banner()).toBeNull();
});
it("shows the elevated warning", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
expect(banner()?.textContent).toContain("running low on memory");
});
it("shows the OOM-restart notice even when current pressure is ok", async () => {
await render(
<MemoryPressureBanner
status={statusWith({ pressure: "ok", last_boot_suspected_oom: true })}
/>,
);
expect(banner()?.textContent).toContain(
"restarted unexpectedly, most likely because it ran out of memory",
);
});
it("critical pressure outranks the OOM-restart notice", async () => {
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
last_boot_suspected_oom: true,
})}
/>,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("dismissal hides the banner and persists across re-renders", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
expect(banner()).toBeNull();
});
it("escalation to critical re-opens a dismissed banner", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("a NEW OOM restart (different boot_id) re-opens a dismissed OOM notice", async () => {
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "ok",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Same boot: stays dismissed.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "ok",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
expect(banner()).toBeNull();
// The gateway OOM-loops and boots again: new incident, banner returns.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "ok",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T02:00:00+00:00",
})}
/>,
);
expect(banner()?.textContent).toContain("restarted unexpectedly");
});
it("a gateway reboot (boot_id change) re-opens a dismissed live-pressure banner", async () => {
// Edge case: dismiss critical, gateway restarts, next poll is STILL
// critical with no observed "ok" in between. The new boot is a new
// incident — without boot-scoped keys it would stay hidden (and mask
// the OOM notice too, since critical takes precedence).
await render(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Same boot, still critical: stays dismissed.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
boot_id: "2026-08-13T01:00:00+00:00",
})}
/>,
);
expect(banner()).toBeNull();
// Rebooted, still critical: new incident, banner returns.
await rerender(
<MemoryPressureBanner
status={statusWith({
pressure: "critical",
last_boot_suspected_oom: true,
boot_id: "2026-08-13T02:00:00+00:00",
})}
/>,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("recovery to ok resets a dismissed live-pressure banner for the next episode", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Confirmed recovery clears live dismissals...
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "ok" })} />,
);
expect(banner()).toBeNull();
// ...so the NEXT critical episode surfaces again.
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "critical" })} />,
);
expect(banner()?.textContent).toContain("almost out of memory");
});
it("unknown pressure (stale heartbeat) does NOT reset live dismissals", async () => {
await render(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "unknown" })} />,
);
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWith({ pressure: "elevated" })} />,
);
expect(banner()).toBeNull();
});
it("renders nothing for healthy or unknown disk", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "ok", free_mb: 5000 })}
/>,
);
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner status={statusWithDisk({ pressure: "unknown" })} />,
);
expect(banner()).toBeNull();
});
it("shows the disk-critical warning with free-space detail", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 120 })}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
expect(banner()?.textContent).toContain("(120 MB free)");
});
it("shows the disk-elevated warning", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "elevated", free_mb: 900 })}
/>,
);
expect(banner()?.textContent).toContain("disk is filling up");
});
it("disk critical outranks memory critical", async () => {
// Imminent data loss beats imminent restart.
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "critical", free_mb: 100 },
{ pressure: "critical" },
)}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
it("memory OOM notice outranks disk elevated", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "elevated", free_mb: 900 },
{ pressure: "ok", last_boot_suspected_oom: true },
)}
/>,
);
expect(banner()?.textContent).toContain("restarted unexpectedly");
});
it("dismissing a disk warning does not mask a later memory warning", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "elevated", free_mb: 900 })}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "elevated", free_mb: 900 },
{ pressure: "elevated" },
)}
/>,
);
expect(banner()?.textContent).toContain("running low on memory");
});
it("disk escalation to critical re-opens a dismissed disk banner", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "elevated", free_mb: 900 })}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
await rerender(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 150 })}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
it("disk recovery to ok resets disk dismissals for the next episode", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 150 })}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
// User frees space (or grows the disk)...
await rerender(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "ok", free_mb: 8000 })}
/>,
);
expect(banner()).toBeNull();
// ...then the disk fills again: new episode surfaces.
await rerender(
<MemoryPressureBanner
status={statusWithDisk({ pressure: "critical", free_mb: 150 })}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
it("disk recovery does NOT reset memory dismissals (and vice versa)", async () => {
// Dismiss a memory warning while disk is also elevated.
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "elevated", free_mb: 900 },
{ pressure: "critical" },
)}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
// Trigger shown is memory critical (outranks disk elevated) — dismiss it.
await act(async () => dismiss.click());
// Disk warning is next in line and has its own key, so it surfaces...
expect(banner()?.textContent).toContain("disk is filling up");
const dismissDisk = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismissDisk.click());
expect(banner()).toBeNull();
// Disk recovers; memory still critical — its dismissal must survive.
await rerender(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "ok", free_mb: 8000 },
{ pressure: "critical" },
)}
/>,
);
expect(banner()).toBeNull();
});
it("a gateway reboot (boot_id change) re-opens a dismissed disk banner", async () => {
await render(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "critical", free_mb: 150 },
{ pressure: "ok", boot_id: "2026-08-13T01:00:00+00:00" },
)}
/>,
);
const dismiss = container.querySelector(
'[data-testid="memory-pressure-banner"] button',
) as HTMLButtonElement;
await act(async () => dismiss.click());
expect(banner()).toBeNull();
// Restart with the disk still full: new boot, warning returns.
await rerender(
<MemoryPressureBanner
status={statusWithDisk(
{ pressure: "critical", free_mb: 150 },
{ pressure: "ok", boot_id: "2026-08-13T02:00:00+00:00" },
)}
/>,
);
expect(banner()?.textContent).toContain("disk is almost full");
});
});