mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
70b6f44f13
* 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.
292 lines
9.8 KiB
TypeScript
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();
|
|
});
|
|
});
|