Add memory settings UX tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 20:28:27 -05:00
1 parent f4ce06638f
commit 22b4d8a176
2 files changed
+308

No files matched your search

@@ -0,0 +1,173 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { MemoryProviderDescriptor } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryProviderConfigForm } from "./MemoryProviderConfigForm";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const provider: MemoryProviderDescriptor = {
key: "qmd_memory",
displayName: "QMD Memory",
description: "Markdown memory",
kind: "plugin",
pluginId: "paperclip.qmd-memory",
capabilities: {
browse: true,
correction: false,
asyncIngestion: false,
providerManagedExtraction: false,
},
configSchema: null,
configMetadata: {
suggestedConfig: {
searchMode: "query",
topK: 5,
apiToken: "",
autoIndexOnWrite: true,
},
fields: [
{
key: "searchMode",
label: "Search mode",
description: "How QMD should retrieve matching memory files.",
input: "select",
options: [
{ value: "query", label: "Query" },
{ value: "search", label: "Search" },
],
},
{
key: "topK",
label: "Hydration snippets",
description: "Maximum snippets to include in a run preamble.",
input: "number",
min: 1,
max: 25,
},
{
key: "apiToken",
label: "API token",
input: "secret",
required: true,
},
{
key: "autoIndexOnWrite",
label: "Auto-index on write",
input: "boolean",
},
],
pathSuggestions: [
{
key: "store",
label: "Binding directory",
path: "/tmp/paperclip/qmd/default",
description: "Markdown records are stored here.",
},
],
healthChecks: [
{
key: "qmd",
label: "QMD binary",
status: "warning",
message: "qmd was not found on PATH.",
},
],
},
};
describe("MemoryProviderConfigForm", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("renders provider metadata fields, secret inputs, paths, health, and advanced JSON", async () => {
const onChange = vi.fn();
const onValidationChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryProviderConfigForm
provider={provider}
value={{
searchMode: "query",
topK: 5,
apiToken: "secret-token",
autoIndexOnWrite: true,
}}
onChange={onChange}
onValidationChange={onValidationChange}
/>,
);
await Promise.resolve();
});
expect(container.textContent).toContain("Search mode");
expect(container.textContent).toContain("Hydration snippets");
expect(container.textContent).toContain("Binding directory");
expect(container.textContent).toContain("/tmp/paperclip/qmd/default");
expect(container.textContent).toContain("QMD binary");
expect(container.textContent).toContain("warning");
expect(container.querySelector('input[type="password"]')).not.toBeNull();
const advancedButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Advanced JSON")
);
expect(advancedButton).toBeTruthy();
await act(async () => {
advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.querySelector("textarea")?.value).toContain('"topK": 5');
await act(async () => {
root.unmount();
});
});
it("shows field-specific validation and reports invalid config state", async () => {
const onChange = vi.fn();
const onValidationChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MemoryProviderConfigForm
provider={provider}
value={{
searchMode: "missing",
topK: 99,
apiToken: "",
autoIndexOnWrite: "yes",
}}
onChange={onChange}
onValidationChange={onValidationChange}
/>,
);
await Promise.resolve();
});
expect(container.textContent).toContain("Search mode must be one of the listed options.");
expect(container.textContent).toContain("Hydration snippets must be at most 25.");
expect(container.textContent).toContain("API token is required.");
expect(container.textContent).toContain("Auto-index on write must be on or off.");
expect(onValidationChange).toHaveBeenLastCalledWith(false);
await act(async () => {
root.unmount();
});
});
});
@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { MemoryBinding, MemoryResolvedBinding } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "../context/ToastContext";
import { ProjectMemorySettings } from "./ProjectMemorySettings";
const memoryApiMocks = vi.hoisted(() => ({
listBindings: vi.fn(),
getProjectBinding: vi.fn(),
setProjectBinding: vi.fn(),
}));
vi.mock("../api/memory", () => ({
memoryApi: memoryApiMocks,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function tick() {
return new Promise((resolve) => window.setTimeout(resolve, 0));
}
function createBinding(overrides: Partial<MemoryBinding> = {}): MemoryBinding {
return {
id: "binding-qmd",
companyId: "company-1",
key: "qmd-default",
name: "QMD default",
providerKey: "qmd_memory",
config: {},
enabled: true,
createdAt: new Date("2026-04-20T00:00:00.000Z"),
updatedAt: new Date("2026-04-20T00:00:00.000Z"),
...overrides,
};
}
function createResolvedBinding(overrides: Partial<MemoryResolvedBinding> = {}): MemoryResolvedBinding {
return {
companyId: "company-1",
targetType: "company",
targetId: "company-1",
binding: createBinding(),
source: "company_default",
checkedTargetTypes: ["project", "company"],
...overrides,
};
}
describe("ProjectMemorySettings", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
memoryApiMocks.listBindings.mockReset();
memoryApiMocks.getProjectBinding.mockReset();
memoryApiMocks.setProjectBinding.mockReset();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("shows inherited company memory and saves a project override", async () => {
const binding = createBinding();
memoryApiMocks.listBindings.mockResolvedValue([binding]);
memoryApiMocks.getProjectBinding.mockResolvedValue(createResolvedBinding());
memoryApiMocks.setProjectBinding.mockResolvedValue({
id: "target-project",
companyId: "company-1",
bindingId: binding.id,
targetType: "project",
targetId: "project-1",
createdAt: new Date("2026-04-20T00:00:00.000Z"),
updatedAt: new Date("2026-04-20T00:00:00.000Z"),
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ProjectMemorySettings companyId="company-1" projectId="project-1" />
</ToastProvider>
</QueryClientProvider>,
);
});
await act(async () => {
await tick();
await tick();
});
expect(container.textContent).toContain("QMD default (qmd_memory)");
expect(container.textContent).toContain("Source: Company default");
const select = container.querySelector("select");
expect(select).not.toBeNull();
await act(async () => {
select!.value = binding.id;
select!.dispatchEvent(new Event("change", { bubbles: true }));
});
const saveButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Save override")
);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
});
expect(memoryApiMocks.setProjectBinding).toHaveBeenCalledWith("project-1", binding.id);
await act(async () => {
root.unmount();
});
});
});