Files
openclaw/ui/src/components/sidebar-update-card.test.ts
Peter Steinberger 70b6f44f13 improve: one alert surface, explained by Ask OpenClaw (#126155)
* refactor(ui): unify sidebar alerts with custodian

* refactor(ui): move custodian alert state

* fix(ui): use the defined mono token in the custodian alert card

* fix(ui): break the update-watcher import cycle

Type the watcher against a structural leaf contract instead of
Pick<ApplicationContext,...>; context.ts reaches this module through
overlays-types.ts, so naming the context type closed a madge cycle.

* fix(ui): re-arm the alert explanation when an incident recurs

Failed-automation and model-auth alerts keep one incident id across
recover-then-fail-again, so an id-keyed dedupe showed the renewed alert
and never explained it. Scope ask-once to the presentation instead.
2026-08-19 21:33:31 -07:00

292 lines
9.8 KiB
TypeScript

/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { NATIVE_UPDATE_DECLINED_EVENT } from "../app/native-link-routing.ts";
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
import "./sidebar-update-card.ts";
type SidebarUpdateCardElement = HTMLElement & {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
heldUpdateCampaignId: string | null;
updateBusy: boolean;
canUpdate: boolean;
canHoldUpdate: boolean;
onUpdate: () => void;
refreshRequired: boolean;
onRefresh: () => void;
onHoldUpdate: () => Promise<boolean>;
statusBanner: ApplicationStatusBanner | null;
onReviewUpdate: () => void;
updateComplete: Promise<boolean>;
};
let originalWebkit: PropertyDescriptor | undefined;
async function mount(
update: UpdateAvailable | null,
schedule: UpdateScheduleState | null = null,
canUpdate = true,
canHoldUpdate = true,
) {
const element = document.createElement(
"openclaw-sidebar-update-card",
) as SidebarUpdateCardElement;
element.updateAvailable = update;
element.updateSchedule = schedule;
element.canUpdate = canUpdate;
element.canHoldUpdate = canHoldUpdate;
document.body.append(element);
await element.updateComplete;
return element;
}
beforeEach(() => {
originalWebkit = Object.getOwnPropertyDescriptor(window, "webkit");
});
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
if (originalWebkit) {
Object.defineProperty(window, "webkit", originalWebkit);
} else {
Reflect.deleteProperty(window, "webkit");
}
});
describe("SidebarUpdateCard", () => {
it("renders the refresh state and invokes its action", async () => {
const element = await mount(null);
const onRefresh = vi.fn();
element.refreshRequired = true;
element.onRefresh = onRefresh;
await element.updateComplete;
const card = element.querySelector(".sidebar-update-card");
expect(card?.getAttribute("role")).toBe("status");
expect(card?.getAttribute("aria-live")).toBe("polite");
expect(element.querySelector(".sidebar-update-card__title")?.textContent).toBe(
"Server updated",
);
expect(element.querySelector(".sidebar-update-card__subtitle")?.textContent).toBe(
"Refresh for full capabilities",
);
element.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onRefresh).toHaveBeenCalledOnce();
});
it("gives the refresh state precedence over an available update", async () => {
const element = await mount({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "stable",
});
const onRefresh = vi.fn();
const onUpdate = vi.fn();
element.refreshRequired = true;
element.onRefresh = onRefresh;
element.onUpdate = onUpdate;
await element.updateComplete;
expect(element.textContent).toContain("Server updated");
expect(element.textContent).not.toContain("Update Gateway");
expect(element.textContent).not.toContain("v2.0.0");
element.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onRefresh).toHaveBeenCalledOnce();
expect(onUpdate).not.toHaveBeenCalled();
});
it("routes a recorded failure to update settings when availability is gone", async () => {
const element = await mount(null);
const onReviewUpdate = vi.fn();
element.statusBanner = { tone: "danger", text: "Update failed" };
element.onReviewUpdate = onReviewUpdate;
await element.updateComplete;
expect(element.textContent).toContain("Update failed");
element.querySelector<HTMLButtonElement>(".sidebar-update-card__review")?.click();
expect(onReviewUpdate).toHaveBeenCalledOnce();
});
it("returns a declined native alert update to the gateway", async () => {
const element = await mount({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "stable",
});
const onUpdate = vi.fn();
element.onUpdate = onUpdate;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.updateBusy = true;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.updateBusy = false;
element.updateAvailable = null;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.remove();
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
});
it("narrates the whole update after the Gateway drops its metadata", async () => {
const element = await mount(
{ currentVersion: "1.0.0", latestVersion: "1.0.0", channel: "dev", commitsBehind: 246 },
{
channel: "dev",
autoEnabled: false,
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "abc1234def",
commitsBehind: 246,
},
},
);
expect(element.querySelector(".sidebar-update-card")).toBeNull();
element.updateBusy = true;
await element.updateComplete;
const action = element.querySelector<HTMLButtonElement>(".sidebar-update-card__action");
expect(action?.disabled).toBe(true);
expect(action?.textContent).toContain("Updating Gateway…");
element.updateAvailable = null;
element.updateSchedule = null;
await element.updateComplete;
expect(element.textContent).toContain("Updating Gateway…");
});
it("renders a quiet live countdown and stops ticking on disconnect", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const clearInterval = vi.spyOn(globalThis, "clearInterval");
const element = await mount(
{ currentVersion: "1.0.0", latestVersion: "2.0.0", channel: "stable" },
{
channel: "stable",
autoEnabled: true,
target: { kind: "package", version: "2.0.0" },
campaign: {
id: "campaign-1",
state: "countdown",
announcedAtMs: 0,
applyAtMs: 55_000,
forceAtMs: 900_000,
updatedAtMs: 0,
},
},
);
const card = element.querySelector(".sidebar-update-card");
const timer = element.querySelector("[role='timer']");
expect(card?.hasAttribute("role")).toBe(false);
expect(timer?.getAttribute("aria-live")).toBe("off");
expect(timer?.textContent).toContain("Updating in 0:54 · v2.0.0");
expect(element.querySelector(".sidebar-update-card__hold")?.textContent?.trim()).toBe(
"Hold 1 h",
);
element.updateBusy = true;
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__hold")).toBeNull();
element.updateBusy = false;
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__hold")).not.toBeNull();
await vi.advanceTimersByTimeAsync(1_000);
await element.updateComplete;
expect(element.querySelector("[role='timer']")?.textContent).toContain("Updating in 0:53");
element.remove();
expect(clearInterval).toHaveBeenCalled();
});
it("keeps a consumed hold hidden across shared-state rerenders after expiry", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const element = await mount(
{ currentVersion: "1.0.0", latestVersion: "2.0.0", channel: "stable" },
{
channel: "stable",
autoEnabled: true,
target: { kind: "package", version: "2.0.0" },
campaign: {
id: "campaign-1",
state: "waiting-for-idle",
announcedAtMs: 0,
forceAtMs: 900_000,
updatedAtMs: 0,
},
},
);
const onHoldUpdate = vi.fn(async () => true);
element.onHoldUpdate = onHoldUpdate;
element.querySelector<HTMLButtonElement>(".sidebar-update-card__hold")?.click();
await Promise.resolve();
await element.updateComplete;
expect(onHoldUpdate).toHaveBeenCalledOnce();
element.heldUpdateCampaignId = "campaign-1";
element.updateSchedule = {
...element.updateSchedule!,
campaign: { ...element.updateSchedule!.campaign!, holdUntilMs: 61_000 },
};
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__hold")).toBeNull();
element.updateSchedule = {
...element.updateSchedule!,
campaign: { ...element.updateSchedule!.campaign!, holdUntilMs: 500 },
};
await element.updateComplete;
expect(element.querySelector(".sidebar-update-card__hold")).toBeNull();
});
it("renders held timing and gates hold for active or unauthorized campaigns", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const schedule: UpdateScheduleState = {
channel: "dev",
autoEnabled: true,
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "a".repeat(40),
commitsBehind: 2,
},
campaign: {
id: "campaign-1",
state: "waiting-for-idle",
announcedAtMs: 0,
holdUntilMs: 61_000,
forceAtMs: 961_000,
updatedAtMs: 1_000,
},
};
const held = await mount(null, schedule);
expect(held.textContent).toContain("Update held · resumes in 1:00");
expect(held.querySelector(".sidebar-update-card__hold")).toBeNull();
const unheldSchedule: UpdateScheduleState = {
...schedule,
campaign: { ...schedule.campaign!, holdUntilMs: undefined },
};
const unauthorized = await mount(null, unheldSchedule, false);
expect(unauthorized.querySelector(".sidebar-update-card__hold")).toBeNull();
const unsupported = await mount(null, unheldSchedule, true, false);
expect(unsupported.querySelector(".sidebar-update-card__hold")).toBeNull();
});
});