/** * @file Covers the "Update all" card on the Claude Code config panel's plugins * tab: it starts the detached batch, renders the worker's journal as it polls, * survives the dashboard restart the batch's last step causes, and keeps the * per-plugin update button reloading only that one plugin. * * The restart case is the one worth a test: while AgentHub replaces itself the * status endpoint stops answering, and a naive implementation would render that * as a failure right at the moment everything is going to plan. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const ccConfigMock = vi.hoisted(() => { const r = (value: unknown) => vi.fn().mockResolvedValue(value); return { overview: r({ roots: { claudeHome: "/home/.claude", projectClaudeDir: "/repo/.claude", projectRoot: "/repo", }, counts: { skills: { user: 0, project: 0 }, agents: { user: 0, project: 0 }, commands: { user: 0, project: 0 }, outputStyles: { user: 0, project: 0 }, mcpServers: { user: 0, project: 0 }, hooks: {}, plugins: 1, marketplaces: 0, keybindings: 0, settingsFiles: 0, memory: 0, }, }), skills: r({ items: [] }), agents: r({ items: [] }), commands: r({ items: [] }), outputStyles: r({ items: [] }), memory: r({ items: [] }), plugins: r({ manifestPath: "/home/.claude/plugins/installed_plugins.json", manifestExists: true, plugins: [{ key: "ponytail@ponytail", enabled: true, version: "4.9.0" }], }), marketplaces: r({ marketplaces: [] }), mcp: r({ mcp: [] }), hooks: r({ hooks: [] }), settings: r({ sources: [] }), keybindings: r({ groups: [] }), statusline: r({}), hookScripts: r({ items: [] }), profiles: r({ profiles: [], active: null }), backups: r({ items: [] }), file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }), pluginsAvailable: r({ installed: [], available: [] }), pluginAction: r({ ok: true, stdout: "", stderr: "" }), updateAllStatus: r({ batch: null }), updateAll: r({ ok: true, spawned: true, runtime_dir: "/home/.claude/agenthub/runtime", planned: 7, self_plugin: { marketplace: "sg", plugin: "agenthub" }, }), reload: r({ ok: true, commands: ["/reload-plugins"], lanes: [], lanes_total: 2 }), }; }); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { ...(actual.api as Record), ccConfig: ccConfigMock }, }; }); vi.mock("../../lib/eventBus", () => ({ eventBus: { subscribe: () => () => {}, publish: () => {}, onConnection: () => () => {}, connected: true, setConnected: () => {}, }, })); import { CcConfig } from "../CcConfig"; async function settle() { await act(async () => { await new Promise((res) => setTimeout(res, 0)); await new Promise((res) => setTimeout(res, 0)); }); } /** Open the plugins tab, where the Update all card lives. */ async function openPlugins() { render(); await settle(); await userEvent.click(screen.getByRole("tab", { name: /plugins/i })); await settle(); } afterEach(() => { vi.clearAllMocks(); ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({ batch: null }) as never; ccConfigMock.reload = vi.fn().mockResolvedValue({ ok: true, commands: ["/reload-plugins"], lanes: [], lanes_total: 2, }) as never; }); describe("CcConfig — Update all", () => { it("starts the batch and reports how many plugins it will walk", async () => { await openPlugins(); await userEvent.click(screen.getByRole("button", { name: /update all/i })); await settle(); expect(ccConfigMock.updateAll).toHaveBeenCalledTimes(1); // The count comes from the server's plan, not from the rendered list. expect(await screen.findByText(/7 plugin/i)).toBeInTheDocument(); }); it("renders each step of the worker's journal, including a failure", async () => { ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({ batch: { status: "partial", started_at: new Date().toISOString(), finished_at: new Date().toISOString(), steps: [ { key: "marketplaces", label: "Update all marketplaces", status: "ok" }, { key: "plugin:ponytail@ponytail", label: "ponytail@ponytail", status: "ok" }, { key: "plugin:vercel@claude-plugins-official", label: "vercel@claude-plugins-official", status: "failed", error: "marketplace unreachable", }, { key: "reload", label: "Reload open Claude Code sessions", status: "ok", lanes_total: 3, }, ], }, }) as never; await openPlugins(); expect(await screen.findByText("Update all marketplaces")).toBeInTheDocument(); expect(screen.getByText("ponytail@ponytail")).toBeInTheDocument(); expect(screen.getByText(/marketplace unreachable/)).toBeInTheDocument(); expect(screen.getByText("Reload open Claude Code sessions")).toBeInTheDocument(); }); it("keeps the last known steps on screen when the status endpoint stops answering", async () => { // This is what a successful AgentHub self-update looks like from the tab: // the server it was polling is gone. Rendering an error here would be // wrong — the restart IS the batch working. const runningBatch = { batch: { status: "running", started_at: new Date().toISOString(), steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "ok" }], }, }; ccConfigMock.updateAllStatus = vi .fn() .mockResolvedValueOnce(runningBatch) .mockRejectedValue(new Error("Failed to fetch")) as never; await openPlugins(); expect(await screen.findByText("Update all marketplaces")).toBeInTheDocument(); // Let the polling interval fire into the rejection. await act(async () => { await new Promise((res) => setTimeout(res, 2200)); }); expect(screen.getByText("Update all marketplaces")).toBeInTheDocument(); expect(screen.queryByText(/Failed to fetch/)).not.toBeInTheDocument(); }); it("disables both buttons while a batch is running, so two can't overlap", async () => { ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({ batch: { status: "running", started_at: new Date().toISOString(), steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "running" }], }, }) as never; await openPlugins(); await waitFor(() => { expect(screen.getByRole("button", { name: /updating/i })).toBeDisabled(); }); expect(screen.getByRole("button", { name: /reload sessions/i })).toBeDisabled(); }); it("re-enables the button for an abandoned batch instead of spinning forever", async () => { // A detached worker killed by a reboot leaves a `running` journal; the // server reports it as abandoned so the button is usable again. ccConfigMock.updateAllStatus = vi.fn().mockResolvedValue({ batch: { status: "abandoned", started_at: new Date(Date.now() - 7200_000).toISOString(), steps: [{ key: "marketplaces", label: "Update all marketplaces", status: "running" }], }, }) as never; await openPlugins(); expect(await screen.findByRole("button", { name: /update all/i })).not.toBeDisabled(); }); it("reload-only sends the slash commands without starting a batch", async () => { await openPlugins(); await userEvent.click(screen.getByRole("button", { name: /reload sessions/i })); await settle(); expect(ccConfigMock.reload).toHaveBeenCalledTimes(1); expect(ccConfigMock.updateAll).not.toHaveBeenCalled(); expect(await screen.findByText(/2 open session/i)).toBeInTheDocument(); }); it("says so plainly when there is no open session to reload", async () => { ccConfigMock.reload = vi .fn() .mockResolvedValue({ ok: true, commands: [], lanes: [], lanes_total: 0 }) as never; await openPlugins(); await userEvent.click(screen.getByRole("button", { name: /reload sessions/i })); await settle(); expect(await screen.findByText(/no open claude code session/i)).toBeInTheDocument(); }); it("updating one plugin asks for a reload of just that change", async () => { // The batch reloads once at the end; a single update reloads immediately. await openPlugins(); const [firstUpdate] = screen.getAllByRole("button", { name: /^update$/i }); await userEvent.click(firstUpdate!); await settle(); expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "update", true); }); });