aca35dd4cd
The plugins tab could already update one plugin or one marketplace at a time. It gains an install-wide batch and, separately, the reload step that actually makes an update take effect. Update all runs in a DETACHED worker rather than the request handler, for a structural reason: the batch ends with the plugin the dashboard ITSELF runs from, and that step's plugin-bootstrap.js --force SIGTERMs this very server. An in-process loop would die partway through with the remaining plugins unrun and nothing left to report. Order is marketplaces (one call, no name = all) -> every other installed plugin -> AgentHub last -> one reload. Each transition is journalled to cc-update-all.json in the runtime dir, outside the plugin cache the update replaces, so whichever server is alive serves progress from it; the POST's response is only an acknowledgement. readUpdateAll DERIVES an abandoned status for a running journal older than an hour, because a detached worker killed by a reboot leaves nobody to write the terminal entry and the button would stay wedged forever. isRunning gates a concurrent batch: two claude plugin update chains over one config dir race. Reload is its own capability (server/lib/cc-reload.js, POST /api/cc-config/reload). /reload-plugins and /reload-skills are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from outside - typing into a session already running is the only real mechanism, and that is what this does, over tmux send-keys into the dashboard's live lane panes. It spawns NO claude process on purpose: a one-shot claude -p "/reload-plugins" would reload only its own short-lived session, exit, and leave a junk session in the dashboard's history - a headless run gets a full sessions row and there is no filter anywhere to suppress it. Reusing live panes means no session is created at all. Panes at a shell prompt are skipped (the text would run as a shell command) and only claude lanes are addressed, since Codex has no such command. Update-all reloads once at the end; updating a single plugin passes reload:true and reloads immediately, and never after a failed action. Fixes a real pre-existing bug on the way: pluginUpdate omitted -y while runClaude closes stdin, and the CLI requires it whenever stdin is not a TTY - so the per-plugin Update button failed for any plugin whose marketplace declares an install command. Also completes CcConfigChangedPayload's action union, which claimed write|delete while nine other values were already being emitted. Verified: server 1683/1683, client 486/486 (27 new tests), build, prettier, file-header audit, openapi.yaml regenerated (117 paths). Update all was NOT executed against this machine - it would update 7 real plugins and restart the running dashboard.
260 lines
8.9 KiB
TypeScript
260 lines
8.9 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";
|
|
|
|
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() {
|
|
render(<CcConfig />);
|
|
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);
|
|
});
|
|
});
|