From 22b4d8a17645306ba579426541c504748c425413 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 19 Apr 2026 20:28:27 -0500 Subject: [PATCH] Add memory settings UX tests Co-Authored-By: Paperclip --- .../MemoryProviderConfigForm.test.tsx | 173 ++++++++++++++++++ .../components/ProjectMemorySettings.test.tsx | 135 ++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 ui/src/components/MemoryProviderConfigForm.test.tsx create mode 100644 ui/src/components/ProjectMemorySettings.test.tsx diff --git a/ui/src/components/MemoryProviderConfigForm.test.tsx b/ui/src/components/MemoryProviderConfigForm.test.tsx new file mode 100644 index 00000000000..cc9d911e745 --- /dev/null +++ b/ui/src/components/MemoryProviderConfigForm.test.tsx @@ -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( + , + ); + 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( + , + ); + 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(); + }); + }); +}); diff --git a/ui/src/components/ProjectMemorySettings.test.tsx b/ui/src/components/ProjectMemorySettings.test.tsx new file mode 100644 index 00000000000..6c428dc06fa --- /dev/null +++ b/ui/src/components/ProjectMemorySettings.test.tsx @@ -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 { + 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 { + 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( + + + + + , + ); + }); + + 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(); + }); + }); +});