Files
AgentHub/client/src/pages/__tests__/CcConfig.updateAll.test.tsx
T
nntrivi2001 6715fbd971 feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages
- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
2026-10-05 13:22:08 +07:00

268 lines
9.2 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "../../components/Toaster";
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<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), 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() {
// Toasts go through the app-wide queue, so the test mounts its renderer.
render(
<MemoryRouter>
<CcConfig />
<Toaster />
</MemoryRouter>
);
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);
});
});