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(
<>
>,
);
}
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();
});
});