Files
cc-switch/tests/components/HermesMemoryPanel.test.tsx
Jason c55a36c0bf feat(ui): rebuild the accounts center and Hermes memory pages
Accounts center: each service is now one card with a help tip, "Add
account" and a ⋯ menu ("Delete all accounts…" from two accounts up).
Account rows show the default and re-login badges, the sign-in date, how
many providers use the account, the remaining quota with a refresh time,
and a ⋯ menu for set default, sign in again and delete. Waiting, loading,
load-failure and login-failure states are drawn in place. Deleting still
confirms first and names the affected providers. Provider forms keep
their account picker.

Hermes memory: the save button moves to the page header and is the only
solid button. The file tabs mark unsaved edits with a dot and keep drafts
when switching. The enable row shows the real file path, or that Hermes
skips the file while it is off; the editor is a plain monospace area; the
meter shows characters remaining and warns when over the limit. When
changes take effect is said in the toasts, and "Adjust limit in Hermes
Web UI" asks before starting the Web UI.
2026-10-03 23:28:58 +08:00

187 lines
5.7 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
limits: {
memory: 2200,
user: 1375,
memoryEnabled: true,
userEnabled: true,
},
save: vi.fn(),
toggle: vi.fn(),
openWebUI: vi.fn(),
toastSuccess: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { success: mocks.toastSuccess, error: vi.fn() },
}));
vi.mock("@/hooks/useHermes", () => ({
useHermesMemory: (kind: string) => ({
data: kind === "memory" ? "agent notes" : "user profile",
isLoading: false,
isError: false,
}),
useHermesMemoryLimits: () => ({ data: mocks.limits }),
useHermesConfigDir: () => ({ data: "/Users/octocat/.hermes" }),
useOpenHermesWebUI: () => mocks.openWebUI,
useSaveHermesMemory: () => ({ mutateAsync: mocks.save, isPending: false }),
useToggleHermesMemoryEnabled: () => ({
mutate: mocks.toggle,
isPending: false,
}),
}));
import HermesMemoryPanel, {
HermesMemorySaveButton,
} from "@/components/hermes/HermesMemoryPanel";
function renderPage(onOpenWebUI?: (path?: string) => void) {
return render(
<>
<HermesMemorySaveButton />
<HermesMemoryPanel onOpenWebUI={onOpenWebUI} />
</>,
);
}
const editors = () =>
screen.getAllByLabelText("hermes.memory.editorLabel", {
selector: "textarea",
});
describe("HermesMemoryPanel", () => {
beforeEach(() => {
mocks.limits = {
memory: 2200,
user: 1375,
memoryEnabled: true,
userEnabled: true,
};
mocks.save.mockReset().mockResolvedValue(undefined);
mocks.toggle.mockReset();
mocks.openWebUI.mockReset();
mocks.toastSuccess.mockReset();
});
it("switches memory files with secondary underline tabs and keeps unsaved edits", () => {
renderPage();
const tabs = screen.getAllByRole("tab");
expect(tabs.map((tab) => tab.textContent)).toEqual([
"hermes.memory.agentNameMEMORY.md",
"hermes.memory.userNameUSER.md",
]);
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("group")).not.toBeInTheDocument();
expect(
screen.queryByRole("img", { name: "hermes.memory.unsaved" }),
).not.toBeInTheDocument();
fireEvent.change(editors()[0], { target: { value: "draft" } });
// 有未保存修改的那份页签上出现小圆点
expect(tabs[0]).toContainElement(
screen.getByRole("img", { name: "hermes.memory.unsaved" }),
);
fireEvent.click(tabs[1]);
expect(tabs[1]).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel")).toHaveAttribute(
"aria-labelledby",
"hermes-memory-user",
);
fireEvent.click(tabs[0]);
expect(editors()[0]).toHaveValue("draft");
});
it("saves the active file from the header button and clears the dot", async () => {
renderPage();
const save = screen.getByRole("button", { name: "common.save" });
expect(save).toHaveAttribute("aria-disabled", "true");
fireEvent.click(save);
expect(mocks.save).not.toHaveBeenCalled();
fireEvent.click(screen.getAllByRole("tab")[1]);
fireEvent.change(editors()[1], { target: { value: "new profile" } });
const enabledSave = screen.getByRole("button", { name: "common.save" });
expect(enabledSave).not.toHaveAttribute("aria-disabled");
fireEvent.click(enabledSave);
await waitFor(() =>
expect(mocks.save).toHaveBeenCalledWith({
kind: "user",
content: "new profile",
}),
);
// 「下次启动或新建会话时生效」是后果,写在保存成功的 toast 里
await waitFor(() =>
expect(mocks.toastSuccess).toHaveBeenCalledWith(
"hermes.memory.saveSuccess",
),
);
expect(
screen.queryByRole("img", { name: "hermes.memory.unsaved" }),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save" })).toHaveAttribute(
"aria-disabled",
"true",
);
});
it("shows the file path when enabled and the skip warning when disabled", () => {
mocks.limits = { ...mocks.limits, userEnabled: false };
renderPage();
const [agentPanel, userPanel] = screen.getAllByRole("tabpanel", {
hidden: true,
});
expect(agentPanel).toHaveTextContent("~/.hermes/memories/MEMORY.md");
expect(userPanel).toHaveTextContent("hermes.memory.disabledHint");
expect(userPanel).not.toHaveTextContent("USER.md");
const switches = screen.getAllByRole("switch", { hidden: true });
fireEvent.click(switches[1]);
expect(mocks.toggle).toHaveBeenCalledWith(
{ kind: "user", enabled: true },
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
mocks.toggle.mock.calls[0][1].onSuccess();
expect(mocks.toastSuccess).toHaveBeenCalledWith("hermes.memory.toggledOn");
});
it("writes the budget as remaining characters and flags overflow", () => {
mocks.limits = { ...mocks.limits, memory: 10 };
renderPage();
const meter = screen.getAllByRole("meter")[0];
expect(meter).toHaveAttribute("aria-valuenow", "0");
expect(screen.getAllByRole("tabpanel")[0]).toHaveTextContent(
"hermes.memory.overLimitBy",
);
fireEvent.change(editors()[0], { target: { value: "short" } });
expect(screen.getAllByRole("meter")[0]).toHaveAttribute(
"aria-valuenow",
"5",
);
expect(screen.getAllByRole("tabpanel")[0]).toHaveTextContent(
"hermes.memory.remaining",
);
});
it("opens the Hermes Web UI config page through the caller's handler", () => {
const onOpen = vi.fn();
renderPage(onOpen);
fireEvent.click(
screen.getByRole("button", { name: /hermes.memory.openConfig/ }),
);
expect(onOpen).toHaveBeenCalledWith("/config");
expect(mocks.openWebUI).not.toHaveBeenCalled();
});
});