6c605849d8
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
/**
|
|
* @file ClaudeAgentHubSettings.test.tsx
|
|
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
|
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
|
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import i18n from "i18next";
|
|
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
|
import { api } from "../../../lib/api";
|
|
import type { ModelPricing } from "../../../lib/types";
|
|
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
|
|
|
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
|
function confirmYes() {
|
|
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
|
}
|
|
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
|
settings: {
|
|
info: vi.fn(),
|
|
reinstallHooks: vi.fn(),
|
|
resetPricing: vi.fn(),
|
|
claudeHome: { get: vi.fn(), set: vi.fn() },
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const RULE = {
|
|
model_pattern: "claude-opus-5-5%",
|
|
display_name: "Claude Opus 5.5",
|
|
input_per_mtok: 4,
|
|
output_per_mtok: 20,
|
|
cache_read_per_mtok: 0.2,
|
|
cache_write_per_mtok: 5,
|
|
cache_write_1h_per_mtok: 8,
|
|
fast_input_per_mtok: 8,
|
|
fast_output_per_mtok: 40,
|
|
updated_at: "2026-09-28 10:00:00",
|
|
} as ModelPricing;
|
|
|
|
beforeEach(() => {
|
|
i18n.changeLanguage("en");
|
|
vi.mocked(api.pricing.list)
|
|
.mockReset()
|
|
.mockResolvedValue({ pricing: [RULE] });
|
|
vi.mocked(api.settings.info)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
hooks: {
|
|
installed: true,
|
|
path: "/home/u/.claude/settings.json",
|
|
hooks: { SessionStart: true, Stop: true },
|
|
},
|
|
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
|
vi.mocked(api.settings.claudeHome.get)
|
|
.mockReset()
|
|
.mockResolvedValue({ claude_home: "/home/u/.claude" });
|
|
vi.mocked(api.settings.claudeHome.set)
|
|
.mockReset()
|
|
.mockResolvedValue({ ok: true, claude_home: "/srv/claude" });
|
|
vi.mocked(api.settings.resetPricing).mockReset();
|
|
vi.mocked(api.settings.reinstallHooks)
|
|
.mockReset()
|
|
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
|
|
});
|
|
|
|
describe("ClaudeAgentHubSettings", () => {
|
|
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
|
const { container } = render(<ClaudeAgentHubSettings />);
|
|
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
|
|
for (const id of ["pricing", "hooks", "claude-home"]) {
|
|
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
|
}
|
|
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
|
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
|
|
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
|
|
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
|
|
});
|
|
|
|
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
|
|
const user = userEvent.setup();
|
|
const { container } = render(<ClaudeAgentHubSettings />);
|
|
const input = await screen.findByDisplayValue("/home/u/.claude");
|
|
await user.clear(input);
|
|
await user.type(input, "/srv/claude");
|
|
const home = container.querySelector("section#claude-home") as HTMLElement;
|
|
const off = confirmYes();
|
|
await user.click(within(home).getByRole("button", { name: "Save" }));
|
|
await waitFor(() => expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude"));
|
|
off();
|
|
|
|
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
|
await user.click(within(hooks).getByRole("button"));
|
|
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-reads hook status and tells the page after a CLAUDE_HOME save", async () => {
|
|
const onHomeChanged = vi.fn();
|
|
const user = userEvent.setup();
|
|
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
|
|
const input = await screen.findByDisplayValue("/home/u/.claude");
|
|
await user.clear(input);
|
|
await user.type(input, "/srv/claude");
|
|
const home = container.querySelector("section#claude-home") as HTMLElement;
|
|
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
|
const off = confirmYes();
|
|
await user.click(within(home).getByRole("button", { name: "Save" }));
|
|
await waitFor(() => expect(onHomeChanged).toHaveBeenCalledTimes(1));
|
|
off();
|
|
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
|
});
|
|
|
|
it("resets pricing only on the second click", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
|
render(<ClaudeAgentHubSettings />);
|
|
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
|
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
|
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
|
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
|
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
|
});
|
|
});
|