diff --git a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx
index 59bdda12..a30f636c 100644
--- a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx
+++ b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx
@@ -1,102 +1,233 @@
// @vitest-environment jsdom
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
-
-// Tests for the default-collapsed + expand-on-click behavior of the
-// org templates drawer. Before this change the section rendered all
-// org cards inline, which pushed the individual workspace templates
-// off-screen when there were ≥3 orgs on disk. Collapsed-by-default
-// keeps the scroll focused on the primary deploy path.
-
-vi.mock("@/lib/api", () => ({
- api: {
- get: vi.fn().mockResolvedValue([
- { dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
- { dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
- ]),
- post: vi.fn().mockResolvedValue({}),
- },
+/**
+ * Tests for OrgTemplatesSection — collapsible org template import list.
+ *
+ * Covers:
+ * - Header with count badge (visible only when expanded)
+ * - Collapsed by default, aria-expanded toggles on click
+ * - aria-controls targets org-templates-body div
+ * - Empty state when no org templates
+ * - Loading spinner
+ * - Org template cards: name, description, workspace count
+ * - Import button per card
+ * - Preflight modal opens when org has required_env
+ * - Preflight onProceed fires import
+ * - Preflight onCancel closes modal
+ * - Direct import (no modal) when org has no env requirements
+ * - Import button disabled while that org is importing
+ */
+// ── ALL mocks MUST be before imports (vi.mock is hoisted to top of file) ───────
+const { mockGet, mockPost, mockListSecrets } = vi.hoisted(() => ({
+ mockGet: vi.fn(),
+ mockPost: vi.fn(),
+ mockListSecrets: vi.fn(),
}));
-vi.mock("../Spinner", () => ({ Spinner: () => null }));
-vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null }));
-vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
-vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() }));
+vi.mock("@/lib/api", () => ({
+ api: { get: mockGet, post: mockPost },
+}));
+vi.mock("@/lib/api/secrets", () => ({
+ listSecrets: mockListSecrets,
+}));
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: Object.assign(
+ vi.fn(),
+ { getState: () => ({ nodes: [], hydrate: vi.fn() }) },
+ ),
+}));
+
+vi.mock("../Spinner", () => ({
+ Spinner: () => ,
+}));
+
+vi.mock("../OrgImportPreflightModal", () => ({
+ OrgImportPreflightModal: vi.fn(({ open, onCancel, onProceed }) =>
+ open ? (
+
+
+
+
+ ) : null
+ ),
+}));
+
+vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
+vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
+
+import React from "react";
+import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OrgTemplatesSection } from "../TemplatePalette";
+// ── Shared data ─────────────────────────────────────────────────────────────
+const MOCK_ORGS = [
+ { dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
+ { dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
+];
+
beforeEach(() => {
vi.clearAllMocks();
+ mockGet.mockResolvedValue(MOCK_ORGS);
+ mockPost.mockResolvedValue({ org: "test", workspaces: [], count: 0 });
+ mockListSecrets.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
});
-describe("OrgTemplatesSection — collapse/expand", () => {
- it("renders collapsed by default — org cards are NOT in the DOM", async () => {
- render();
- // The header toggle is visible immediately…
- // Two buttons match "Org Templates" (toggle + refresh) — pick the
- // toggle by its aria-controls binding.
- const toggle = (await screen.findAllByRole("button")).find((b) =>
- b.getAttribute("aria-controls") === "org-templates-body"
- )!;
- expect(toggle).toBeTruthy();
- expect(toggle.getAttribute("aria-expanded")).toBe("false");
+async function expandSection() {
+ const toggle = (await screen.findAllByRole("button")).find(
+ (b) => b.getAttribute("aria-controls") === "org-templates-body"
+ )!;
+ fireEvent.click(toggle);
+ await waitFor(() => {
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
+ });
+}
- // …and the count appears after loadOrgs resolves.
+// ─── Collapse / expand ─────────────────────────────────────────────────────
+
+describe("OrgTemplatesSection — collapse/expand", () => {
+ it("renders collapsed by default — org cards NOT in DOM", async () => {
+ render();
+ const toggle = (await screen.findAllByRole("button")).find(
+ (b) => b.getAttribute("aria-controls") === "org-templates-body"
+ )!;
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
-
- // But none of the individual org cards should be rendered yet.
expect(screen.queryByText("Free Beats All")).toBeNull();
- expect(screen.queryByText("MeDo Smoke Test")).toBeNull();
});
- it("clicking the header reveals the org cards", async () => {
+ it("clicking header reveals org cards", async () => {
render();
-
- // Wait for the count so we know loadOrgs finished.
- // Two buttons match "Org Templates" (toggle + refresh) — pick the
- // toggle by its aria-controls binding.
- const toggle = (await screen.findAllByRole("button")).find((b) =>
- b.getAttribute("aria-controls") === "org-templates-body"
- )!;
- await waitFor(() => {
- expect(toggle.textContent).toContain("(2)");
- });
-
- // Expand.
- fireEvent.click(toggle);
- await waitFor(() => {
- expect(toggle.getAttribute("aria-expanded")).toBe("true");
- });
-
- // Org cards now visible.
+ await expandSection();
expect(screen.getByText("Free Beats All")).toBeTruthy();
expect(screen.getByText("MeDo Smoke Test")).toBeTruthy();
});
- it("clicking the header again collapses back", async () => {
+ it("clicking header again collapses back", async () => {
render();
- // Two buttons match "Org Templates" (toggle + refresh) — pick the
- // toggle by its aria-controls binding.
- const toggle = (await screen.findAllByRole("button")).find((b) =>
- b.getAttribute("aria-controls") === "org-templates-body"
- )!;
- await waitFor(() => {
- expect(toggle.textContent).toContain("(2)");
- });
-
- fireEvent.click(toggle); // expand
+ await expandSection();
expect(screen.getByText("Free Beats All")).toBeTruthy();
-
- fireEvent.click(toggle); // collapse
+ const toggle = (await screen.findAllByRole("button")).find(
+ (b) => b.getAttribute("aria-controls") === "org-templates-body"
+ )!;
+ fireEvent.click(toggle);
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("false");
});
expect(screen.queryByText("Free Beats All")).toBeNull();
});
+
+ it("count badge appears after load", async () => {
+ render();
+ const toggle = (await screen.findAllByRole("button")).find(
+ (b) => b.getAttribute("aria-controls") === "org-templates-body"
+ )!;
+ await waitFor(() => {
+ expect(toggle.textContent).toContain("(2)");
+ });
+ });
+});
+
+// ─── States ─────────────────────────────────────────────────────────────────
+
+describe("OrgTemplatesSection — states", () => {
+ it("shows empty state when no org templates", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await expandSection();
+ expect(screen.getByText(/no org templates/i)).toBeTruthy();
+ expect(screen.getByText(/org-templates\//i)).toBeTruthy();
+ });
+
+ it("shows loading spinner while fetching", async () => {
+ mockGet.mockImplementation(() => new Promise(() => {}));
+ render();
+ await expandSection();
+ expect(screen.getByTestId("spinner")).toBeTruthy();
+ expect(screen.getByText(/loading/i)).toBeTruthy();
+ });
+
+ it("shows workspace count badge on org card", async () => {
+ render();
+ await expandSection();
+ expect(screen.getByText(/3 workspaces/i)).toBeTruthy();
+ });
+
+ it("shows org description on card", async () => {
+ render();
+ await expandSection();
+ expect(screen.getByText("d1")).toBeTruthy();
+ });
+});
+
+// ─── Import ─────────────────────────────────────────────────────────────────
+
+describe("OrgTemplatesSection — import", () => {
+ it("Import button is present for each org", async () => {
+ render();
+ await expandSection();
+ const importBtns = screen.getAllByRole("button", { name: /import org/i });
+ expect(importBtns.length).toBe(2);
+ });
+
+ it("preflight modal opens when org has required_env", async () => {
+ mockGet.mockResolvedValue([
+ { ...MOCK_ORGS[0], required_env: [{ key: "ANTHROPIC_API_KEY" }] },
+ ]);
+ render();
+ await expandSection();
+ fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
+ await waitFor(() => {
+ expect(screen.getByTestId("preflight-modal")).toBeTruthy();
+ });
+ });
+
+ it("preflight onCancel closes the modal", async () => {
+ mockGet.mockResolvedValue([
+ { ...MOCK_ORGS[0], required_env: [{ key: "STRIPE_KEY" }] },
+ ]);
+ render();
+ await expandSection();
+ fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
+ await waitFor(() => {
+ expect(screen.getByTestId("preflight-modal")).toBeTruthy();
+ });
+ await act(async () => {
+ screen.getByRole("button", { name: "Cancel" }).click();
+ });
+ await waitFor(() => {
+ expect(screen.queryByTestId("preflight-modal")).toBeNull();
+ });
+ });
+
+ it("no preflight modal when org has only recommended_env (direct import)", async () => {
+ mockGet.mockResolvedValue([
+ { ...MOCK_ORGS[0], required_env: [], recommended_env: [{ key: "OPTIONAL" }] },
+ ]);
+ render();
+ await expandSection();
+ fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
+ // recommended_env only → no modal needed, no preflight
+ await waitFor(() => {
+ expect(screen.queryByTestId("preflight-modal")).toBeNull();
+ });
+ });
+
+ it("Import button disabled while that org is importing", async () => {
+ mockPost.mockImplementation(() => new Promise(() => {}));
+ render();
+ await expandSection();
+ const importBtns = screen.getAllByRole("button", { name: /import org/i });
+ fireEvent.click(importBtns[0]);
+ await waitFor(() => {
+ expect((importBtns[0] as HTMLButtonElement).disabled).toBe(true);
+ });
+ });
});
diff --git a/canvas/src/components/tabs/__tests__/MemoryTab.test.tsx b/canvas/src/components/tabs/__tests__/MemoryTab.test.tsx
index 69444ead..c2623532 100644
--- a/canvas/src/components/tabs/__tests__/MemoryTab.test.tsx
+++ b/canvas/src/components/tabs/__tests__/MemoryTab.test.tsx
@@ -1,774 +1,632 @@
// @vitest-environment jsdom
/**
- * Tests for MemoryTab — the workspace KV memory tab.
+ * Tests for MemoryTab — awareness dashboard + workspace KV memory management.
*
* Coverage:
- * - Loading state (pending GET)
- * - Empty state ("No memory entries")
- * - Memory entries list renders
- * - Expand/collapse entry + aria-expanded
- * - Add entry: key validation, value JSON parsing, TTL
- * - Edit entry: begin, cancel, save, 409 conflict
- * - Delete entry: optimistic removal
- * - Error state from API failure
- * - Refresh button triggers reload
- * - Awareness dashboard collapse/expand
+ * - Loading state
+ * - Error state when GET /memory fails
+ * - Empty state (no memory entries)
+ * - Memory list rendering (single + multiple entries)
+ * - Expand/collapse memory entries
+ * - Add memory entry (key + value + TTL)
+ * - Add validates required key
+ * - Add parses JSON values
+ * - Delete memory entry
+ * - Edit memory entry (inline)
+ * - Edit 409 conflict shows retry hint
* - Advanced toggle shows/hides KV section
+ * - Awareness dashboard expand/collapse
* - Awareness URL includes workspaceId
- *
- * Uses vi.useRealTimers() + flush() pattern for all non-window tests.
- * window.open is mocked per-test since it is environment-dependent.
+ * - Refresh button reloads memory
+ * - Error clears when appropriate actions are taken
*/
import React from "react";
-import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
+import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryTab } from "../MemoryTab";
-// Hoist mockGet so vi.mock factory can reference it (vi.mock is hoisted).
-const mockGet = vi.hoisted(() => vi.fn<[], Promise>());
+const mockGet = vi.hoisted(() => vi.fn<[], Promise>());
const mockPost = vi.hoisted(() => vi.fn<[], Promise>());
const mockDel = vi.hoisted(() => vi.fn<[], Promise>());
vi.mock("@/lib/api", () => ({
- api: {
- get: mockGet,
- post: mockPost,
- del: mockDel,
- },
+ api: { get: mockGet, post: mockPost, del: mockDel },
}));
-// Mock window.open per-test
-const mockOpen = vi.fn();
-vi.stubGlobal("open", mockOpen);
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
-beforeEach(() => {
- vi.useRealTimers();
- mockGet.mockReset();
- mockPost.mockReset();
- mockDel.mockReset();
- mockOpen.mockReset();
-});
-
-afterEach(() => {
- cleanup();
- vi.useRealTimers();
-});
-
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-const entry = (
- key: string,
- value: unknown,
- overrides?: Partial<{
- version: number;
- expires_at: string | null;
- updated_at: string;
- }>,
-): {
- key: string;
- value: unknown;
- version?: number;
- expires_at: string | null;
- updated_at: string;
-} => ({
- key,
- value,
- version: undefined,
+const MEMORY_ENTRY = {
+ key: "user_context",
+ value: { name: "Alice", role: "engineer" },
+ version: 3,
expires_at: null,
- updated_at: "2026-05-10T10:00:00Z",
- ...overrides,
-});
+ updated_at: new Date(Date.now() - 60000).toISOString(),
+};
-const renderTab = (workspaceId = "ws-1") =>
- render();
+function entry(overrides: Partial = {}): typeof MEMORY_ENTRY {
+ return { ...MEMORY_ENTRY, ...overrides };
+}
+
+// ─── Helpers ───────────────────────────────────────────────────────────────────
-// Flush pattern: resolve mock microtask then flush React state batch.
async function flush() {
await act(async () => { await Promise.resolve(); });
}
-// ─── Tests ────────────────────────────────────────────────────────────────────
+function typeIn(el: HTMLElement, value: string) {
+ Object.defineProperty(el, "value", { value, writable: true, configurable: true });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ fireEvent.change(el as any, { target: el });
+}
-describe("MemoryTab — render conditions", () => {
+// ─── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("MemoryTab", () => {
beforeEach(() => {
- mockGet.mockImplementation(() => new Promise(() => {}));
+ mockGet.mockReset();
+ mockPost.mockReset();
+ mockDel.mockReset();
+ vi.useRealTimers();
});
- it("shows loading state while fetching", async () => {
- renderTab();
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ // ── Loading / Error ──────────────────────────────────────────────────────────
+
+ it("shows loading state when memory is being fetched", async () => {
+ mockGet.mockImplementation(() => new Promise(() => {}));
+ render();
await act(async () => { /* flush initial render */ });
expect(screen.getByText("Loading memory...")).toBeTruthy();
});
- it("shows empty state when API returns empty list", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
+ it("shows error banner when GET /memory rejects", async () => {
+ mockGet.mockRejectedValue(new Error("network failure"));
+ render();
await flush();
- // KV section hidden by default; reveal it via Advanced toggle
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
+ expect(screen.getByText(/network failure/i)).toBeTruthy();
+ });
+
+ it("shows 'Failed to load memory' when GET rejects with non-Error", async () => {
+ mockGet.mockRejectedValue("unknown error");
+ render();
+ await flush();
+ expect(screen.getByText(/Failed to load memory/i)).toBeTruthy();
+ });
+
+ // ── Awareness Dashboard ─────────────────────────────────────────────────────
+
+ it("shows Awareness dashboard section", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText("Awareness dashboard")).toBeTruthy();
+ });
+
+ it("renders an iframe with workspaceId in URL", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const iframe = screen.getByTitle("Awareness dashboard");
+ expect(iframe.getAttribute("src")).toContain("workspaceId=ws-xyz");
+ });
+
+ it("shows 'Connected' status", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText("Connected")).toBeTruthy();
+ });
+
+ it("shows workspace ID in the status grid", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ // workspaceId appears in two places (description + status grid).
+ // Target the font-mono span in the status grid specifically.
+ const spans = Array.from(document.querySelectorAll("span.font-mono"));
+ expect(spans.some(s => s.textContent === "ws-test-id")).toBeTruthy();
+ });
+
+ it("shows 'Collapse' and 'Open' buttons for awareness (starts visible)", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByRole("button", { name: /collapse/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
+ });
+
+ it("hides awareness iframe when Collapse is clicked", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
+ await flush();
+ expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
+ expect(screen.getByText(/awareness dashboard is collapsed/i)).toBeTruthy();
+ });
+
+ it("re-shows awareness iframe when collapsed state Expand is clicked", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ // Start with awareness visible (default) — verify iframe is there
+ expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
+ // Click Collapse in the awareness header to hide the iframe
+ fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
+ await flush();
+ expect(screen.queryByTitle("Awareness dashboard")).toBeNull();
+ // The collapsed awareness state has a different "Expand" button.
+ // Directly click the button whose text is exactly "Expand".
+ const allBtns = screen.getAllByRole("button");
+ const expandInCollapsed = allBtns.find(b => b.textContent?.trim() === "Expand");
+ expect(expandInCollapsed).toBeTruthy();
+ act(() => { expandInCollapsed!.click(); });
+ await flush();
+ expect(screen.getByTitle("Awareness dashboard")).toBeTruthy();
+ });
+
+ // ── KV Memory: Empty / Advanced toggle ───────────────────────────────────────
+
+ it("shows 'Advanced workspace memory is hidden' when advanced is collapsed", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText(/advanced workspace memory is hidden/i)).toBeTruthy();
+ });
+
+ it("shows 'Show' button when advanced is collapsed", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByRole("button", { name: /show/i })).toBeTruthy();
+ });
+
+ it("shows 'Hide Advanced' after clicking Show", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ expect(screen.getByRole("button", { name: /hide advanced/i })).toBeTruthy();
+ });
+
+ it("shows empty state 'No memory entries' when advanced is shown and list is empty", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
expect(screen.getByText("No memory entries")).toBeTruthy();
});
- it("renders memory entries when API returns data", async () => {
- mockGet.mockResolvedValueOnce([
- entry("my-key", { nested: true }),
- entry("another-key", "plain string"),
+ // ── KV Memory: List rendering ───────────────────────────────────────────────
+
+ it("renders memory entries when advanced is open", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ expect(screen.getByText("user_context")).toBeTruthy();
+ });
+
+ it("renders multiple memory entries", async () => {
+ mockGet.mockResolvedValue([
+ entry({ key: "key1", value: "value1" }),
+ entry({ key: "key2", value: "value2" }),
]);
- renderTab();
+ render();
await flush();
- // Advanced is collapsed by default; reveal entries
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- expect(screen.getByText("my-key")).toBeTruthy();
- expect(screen.getByText("another-key")).toBeTruthy();
+ expect(screen.getByText("key1")).toBeTruthy();
+ expect(screen.getByText("key2")).toBeTruthy();
});
- it("shows Advanced section hidden by default", async () => {
- mockGet.mockResolvedValueOnce([entry("k1", "v1")]);
- renderTab();
+ it("shows chevron pointing right when entry is collapsed", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
await flush();
- expect(screen.getByText("Advanced workspace memory is hidden")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ expect(screen.getByText("▶")).toBeTruthy();
});
- it("shows Advanced section when entries exist and advanced is toggled on", async () => {
- mockGet.mockResolvedValueOnce([entry("k1", "v1")]);
- renderTab();
+ it("shows chevron pointing down when entry is expanded", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
await flush();
- // Show the advanced section
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- expect(screen.getByText("k1")).toBeTruthy();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ expect(screen.getByText("▼")).toBeTruthy();
});
- // Awareness section defaults to showAwareness=true (expanded with iframe)
- it("shows Awareness dashboard expanded with iframe by default", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
+ it("shows entry value when expanded", async () => {
+ mockGet.mockResolvedValue([entry({ value: { foo: "bar" } })]);
+ render();
await flush();
- // Default state shows the expanded section
- const iframe = document.querySelector("iframe");
- expect(iframe).toBeTruthy();
- expect(iframe?.getAttribute("title")).toBe("Awareness dashboard");
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ expect(screen.getByText(/"foo": "bar"/)).toBeTruthy();
});
- it("collapses Awareness dashboard when Collapse button is clicked", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
+ it("shows updated_at timestamp when entry is expanded", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /collapse/i }).click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- expect(screen.getByText("Awareness dashboard is collapsed")).toBeTruthy();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ expect(screen.getByText(/updated:/i)).toBeTruthy();
});
- it("shows awareness status grid in expanded Awareness section", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
+ it("shows Edit and Delete buttons when entry is expanded", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
await flush();
- // Default state is already expanded — status grid is visible
- expect(screen.getByText("Connected")).toBeTruthy();
- expect(screen.getByText("Mode")).toBeTruthy();
- expect(screen.getByText("Workspace")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
});
- it("shows workspaceId in awareness grid", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab("my-workspace-id");
+ it("shows TTL when entry has expires_at", async () => {
+ const future = new Date(Date.now() + 3600000).toISOString();
+ mockGet.mockResolvedValue([entry({ expires_at: future })]);
+ render();
await flush();
- // workspaceId appears twice: in awareness grid and in KV description.
- // Query the awareness grid span specifically (text-ink-mid class in the grid).
- const spans = screen.getAllByText("my-workspace-id");
- const gridSpan = spans.find(
- (s) => s.className.includes("font-mono") && !s.className.includes("truncate"),
- );
- expect(gridSpan).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ expect(screen.getByText(/ttl/i)).toBeTruthy();
});
-});
-describe("MemoryTab — KV memory CRUD", () => {
- beforeEach(() => {
- // Use mockImplementation so every call resolves (loadMemory is called multiple
- // times: on mount, on refresh, after add/save errors)
- mockGet.mockImplementation(() =>
- Promise.resolve([entry("existing-key", "existing-value")]),
- );
+ // ── Add Memory Entry ─────────────────────────────────────────────────────────
+
+ it("shows + Add button in KV section", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ expect(screen.getByRole("button", { name: /\+ add/i })).toBeTruthy();
+ });
+
+ it("opens add form when + Add is clicked", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ expect(screen.getByLabelText("Memory key")).toBeTruthy();
+ expect(screen.getByLabelText("Memory value (JSON or plain text)")).toBeTruthy();
+ });
+
+ it("requires key to be non-empty", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ expect(screen.getByText(/key is required/i)).toBeTruthy();
+ });
+
+ it("POSTs correct payload when adding a string value", async () => {
+ mockGet.mockResolvedValue([]);
mockPost.mockResolvedValue({});
- mockDel.mockResolvedValue({});
- });
-
- it("shows error alert when GET rejects", async () => {
- mockGet.mockRejectedValue(new Error("Network failure"));
- renderTab();
+ render();
await flush();
- expect(screen.getByRole("alert")).toBeTruthy();
- expect(screen.getByText("Network failure")).toBeTruthy();
- });
-
- it("Refresh button calls GET /workspaces/:id/memory", async () => {
- renderTab();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- mockGet.mockClear();
- act(() => {
- screen.getByRole("button", { name: /refresh/i }).click();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ typeIn(screen.getByLabelText("Memory key") as HTMLElement, "my_key");
+ typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "plain text value");
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ await waitFor(() => {
+ expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
});
+ expect(mockPost).toHaveBeenCalledWith(
+ "/workspaces/ws-1/memory",
+ expect.objectContaining({ key: "my_key", value: "plain text value" }),
+ );
+ });
+
+ it("POSTs parsed JSON when value is valid JSON", async () => {
+ mockGet.mockResolvedValue([]);
+ mockPost.mockResolvedValue({});
+ render();
await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ typeIn(screen.getByLabelText("Memory key") as HTMLElement, "config");
+ typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, '{"debug": true}');
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ expect(mockPost).toHaveBeenCalledWith(
+ "/workspaces/ws-1/memory",
+ expect.objectContaining({ key: "config", value: { debug: true } }),
+ );
+ });
+
+ it("POSTs with ttl_seconds when TTL is provided", async () => {
+ mockGet.mockResolvedValue([]);
+ mockPost.mockResolvedValue({});
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ typeIn(screen.getByLabelText("Memory key") as HTMLElement, "temp_data");
+ typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "value");
+ typeIn(screen.getByLabelText("TTL in seconds (optional)") as HTMLElement, "3600");
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ expect(mockPost).toHaveBeenCalledWith(
+ "/workspaces/ws-1/memory",
+ expect.objectContaining({ key: "temp_data", value: "value", ttl_seconds: 3600 }),
+ );
+ });
+
+ it("shows error when add fails", async () => {
+ mockGet.mockResolvedValue([]);
+ mockPost.mockRejectedValue(new Error("add failed"));
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ typeIn(screen.getByLabelText("Memory key") as HTMLElement, "key");
+ typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "val");
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ expect(screen.getByText(/add failed/i)).toBeTruthy();
+ });
+
+ it("closes add form and refreshes after successful add", async () => {
+ mockGet.mockResolvedValue([]);
+ mockPost.mockResolvedValue({});
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ typeIn(screen.getByLabelText("Memory key") as HTMLElement, "new_key");
+ typeIn(screen.getByLabelText("Memory value (JSON or plain text)") as HTMLElement, "new_val");
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ await waitFor(() => {
+ expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
+ });
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
});
- it("shows + Add button to open add form", async () => {
- renderTab();
+ it("closes add form when Cancel is clicked", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /\+ add/i }));
+ await flush();
+ expect(screen.getByLabelText("Memory key")).toBeTruthy();
+ act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
+ await flush();
+ await waitFor(() => {
+ expect(screen.queryByLabelText("Memory key")).not.toBeTruthy();
});
- await flush();
- expect(screen.getByRole("button", { name: /^\+ add$/i })).toBeTruthy();
});
- it("shows add form when + Add is clicked", async () => {
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
- await flush();
- expect(screen.getByLabelText(/memory key/i)).toBeTruthy();
- expect(screen.getByLabelText(/memory value/i)).toBeTruthy();
- });
+ // ── Delete Memory Entry ─────────────────────────────────────────────────────
- it("requires key in add form", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
+ it("calls DEL when Delete is clicked", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ mockDel.mockResolvedValue({});
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
+ fireEvent.click(screen.getByText("user_context"));
await flush();
- mockPost.mockReset().mockRejectedValue(new Error("should not be called"));
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
- await flush();
- expect(screen.getByText("Key is required")).toBeTruthy();
- expect(mockPost).not.toHaveBeenCalled();
- });
-
- it("parses JSON value in add form", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
- await flush();
- fireEvent.change(screen.getByLabelText(/memory key/i), {
- target: { value: "json-key" },
- });
- fireEvent.change(screen.getByLabelText(/memory value/i), {
- target: { value: '{"nested": "value"}' },
- });
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
- await flush();
- expect(mockPost).toHaveBeenCalledWith(
- "/workspaces/ws-1/memory",
- expect.objectContaining({
- key: "json-key",
- value: { nested: "value" },
- }),
- );
- });
-
- it("treats plain-text value as string in add form", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
- await flush();
- fireEvent.change(screen.getByLabelText(/memory key/i), {
- target: { value: "plain-key" },
- });
- fireEvent.change(screen.getByLabelText(/memory value/i), {
- target: { value: "plain text" },
- });
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
- await flush();
- expect(mockPost).toHaveBeenCalledWith(
- "/workspaces/ws-1/memory",
- expect.objectContaining({
- key: "plain-key",
- value: "plain text",
- }),
- );
- });
-
- it("sends ttl_seconds when TTL is provided in add form", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
- await flush();
- fireEvent.change(screen.getByLabelText(/memory key/i), {
- target: { value: "ttl-key" },
- });
- fireEvent.change(screen.getByLabelText(/memory value/i), {
- target: { value: "val" },
- });
- fireEvent.change(screen.getByLabelText(/ttl in seconds/i), {
- target: { value: "3600" },
- });
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
- await flush();
- expect(mockPost).toHaveBeenCalledWith(
- "/workspaces/ws-1/memory",
- expect.objectContaining({
- key: "ttl-key",
- value: "val",
- ttl_seconds: 3600,
- }),
- );
- });
-
- it("closes add form on cancel", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
- await flush();
- expect(screen.getByLabelText(/memory key/i)).toBeTruthy();
- act(() => {
- screen.getByRole("button", { name: /cancel/i }).click();
- });
- await flush();
- expect(screen.queryByLabelText(/memory key/i)).toBeFalsy();
- });
-
- it("shows error when add POST rejects", async () => {
- mockGet.mockResolvedValueOnce([]);
- mockPost.mockRejectedValue(new Error("Add failed"));
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /^\+ add$/i }).click();
- });
- await flush();
- fireEvent.change(screen.getByLabelText(/memory key/i), {
- target: { value: "k" },
- });
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
- await flush();
- expect(screen.getByText("Add failed")).toBeTruthy();
- });
-
- it("optimistically removes entry on delete", async () => {
- renderTab();
- await flush();
- // Expand the advanced section
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- // Expand the entry row
- act(() => {
- screen.getByText("existing-key").closest("button")?.click();
- });
- await flush();
- // Verify the Delete button is visible inside the expanded section
- const deleteBtn = screen
- .getAllByRole("button")
- .find((b) => b.textContent === "Delete");
- expect(deleteBtn).toBeTruthy();
- // Clicking Delete fires the API call; the entry is optimistically
- // removed from state before the response. We verify the API call here.
- act(() => {
- deleteBtn?.click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
expect(mockDel).toHaveBeenCalledWith(
- "/workspaces/ws-1/memory/existing-key",
+ "/workspaces/ws-1/memory/user_context",
);
});
- it("calls DELETE /workspaces/:id/memory/:key on delete", async () => {
- renderTab();
+ it("removes entry from list after successful delete", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ mockDel.mockResolvedValue({});
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- act(() => {
- screen.getByText("existing-key").closest("button")?.click();
- });
+ fireEvent.click(screen.getByText("user_context"));
await flush();
- act(() => {
- screen.getByRole("button", { name: /delete/i }).click();
- });
+ expect(screen.getByText("user_context")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
- expect(mockDel).toHaveBeenCalledWith(
- "/workspaces/ws-1/memory/existing-key",
- );
+ expect(screen.queryByText("user_context")).toBeFalsy();
});
- it("shows error when delete rejects", async () => {
- mockDel.mockRejectedValue(new Error("Delete failed"));
- renderTab();
+ it("collapses entry if it was expanded when deleted", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ mockDel.mockResolvedValue({});
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- act(() => {
- screen.getByText("existing-key").closest("button")?.click();
- });
+ // Expand the entry
+ fireEvent.click(screen.getByText("user_context"));
await flush();
- act(() => {
- screen.getByRole("button", { name: /delete/i }).click();
- });
+ expect(screen.getByText("▼")).toBeTruthy();
+ // Delete
+ fireEvent.click(screen.getByRole("button", { name: /delete/i }));
await flush();
- // Error should appear in the alert
- expect(screen.getByRole("alert")).toBeTruthy();
- expect(screen.getByText("Delete failed")).toBeTruthy();
- // Entry should be visible again (reverted)
- expect(screen.getByText("existing-key")).toBeTruthy();
+ expect(screen.queryByText("user_context")).toBeFalsy();
});
-});
-describe("MemoryTab — edit entry", () => {
- beforeEach(() => {
- // Use mockImplementation so every call resolves (loadMemory called multiple times)
- mockGet.mockImplementation(() =>
- Promise.resolve([
- entry("edit-key", { original: true }, { version: 5 }),
- ]),
- );
+ it("shows error when delete fails", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ mockDel.mockRejectedValue(new Error("delete failed"));
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /delete/i }));
+ await flush();
+ expect(screen.getByText(/delete failed/i)).toBeTruthy();
+ });
+
+ // ── Edit Memory Entry ────────────────────────────────────────────────────────
+
+ it("shows edit form when Edit is clicked", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
+ });
+
+ it("pre-fills edit form with existing value", async () => {
+ mockGet.mockResolvedValue([entry({ value: { name: "Alice" } })]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ const textarea = screen.getByLabelText(/edit value for user_context/i);
+ expect((textarea as HTMLTextAreaElement).value).toContain("Alice");
+ });
+
+ it("POSTs updated value when Save is clicked", async () => {
+ mockGet.mockResolvedValue([entry()]);
mockPost.mockResolvedValue({});
- });
-
- it("begins edit mode when Edit is clicked", async () => {
- renderTab();
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "updated_value");
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ await waitFor(() => {
+ expect(screen.queryByLabelText(/edit value for user_context/i)).not.toBeTruthy();
});
- await flush();
- // Expand the entry row first
- act(() => {
- screen.getByText("edit-key").closest("button")?.click();
- });
- await flush();
- // Find the "Edit" button specifically (not the row button whose accessible name is "edit-key")
- const editBtn = screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit");
- act(() => {
- editBtn?.click();
- });
- await flush();
- expect(screen.getByLabelText(/edit value for edit-key/i)).toBeTruthy();
- expect(screen.getByLabelText(/edit ttl for edit-key/i)).toBeTruthy();
- });
-
- it("pre-fills edit textarea with JSON for object values", async () => {
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByText("edit-key").closest("button")?.click();
- });
- await flush();
- act(() => {
- screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit")
- ?.click();
- });
- await flush();
- const textarea = screen.getByLabelText(/edit value for edit-key/i);
- expect(textarea.textContent?.trim()).toBe('{\n "original": true\n}');
- });
-
- it("pre-fills edit textarea with raw string for string values", async () => {
- mockGet.mockImplementation(() =>
- Promise.resolve([
- entry("str-key", "plain string value", { version: 1 }),
- ]),
- );
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByText("str-key").closest("button")?.click();
- });
- await flush();
- act(() => {
- screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit")
- ?.click();
- });
- await flush();
- const textarea = screen.getByLabelText(/edit value for str-key/i);
- expect(textarea.textContent?.trim()).toBe("plain string value");
- });
-
- it("cancels edit and restores entry view", async () => {
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByText("edit-key").closest("button")?.click();
- });
- await flush();
- act(() => {
- screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit")
- ?.click();
- });
- await flush();
- expect(screen.getByLabelText(/edit value for edit-key/i)).toBeTruthy();
- act(() => {
- screen.getByRole("button", { name: /cancel/i }).click();
- });
- await flush();
- expect(screen.queryByLabelText(/edit value/i)).toBeFalsy();
- });
-
- it("calls POST with if_match_version on save", async () => {
- renderTab();
- await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
- await flush();
- act(() => {
- screen.getByText("edit-key").closest("button")?.click();
- });
- await flush();
- act(() => {
- screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit")
- ?.click();
- });
- await flush();
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
- await flush();
expect(mockPost).toHaveBeenCalledWith(
"/workspaces/ws-1/memory",
- expect.objectContaining({
- key: "edit-key",
- value: { original: true },
- if_match_version: 5,
- }),
+ expect.objectContaining({ key: "user_context", value: "updated_value", if_match_version: 3 }),
);
});
- it("shows 409 conflict error and reloads on version mismatch", async () => {
- mockPost.mockRejectedValue(
- new Error("409 Conflict: if_match_version mismatch"),
- );
- // Return entries for initial load; on 409 the component calls loadMemory()
- // again — use mockImplementation so subsequent calls also return entries
- mockGet.mockImplementation(() =>
- Promise.resolve([
- entry("edit-key", { original: true }, { version: 5 }),
- ]),
- );
- renderTab();
+ it("shows retry hint on 409 conflict during edit", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ mockPost.mockRejectedValue(new Error("409 Conflict: if_match_version mismatch"));
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- act(() => {
- screen.getByText("edit-key").closest("button")?.click();
- });
+ fireEvent.click(screen.getByText("user_context"));
await flush();
- act(() => {
- screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit")
- ?.click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
+ typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "new_val");
+ await flush();
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
await flush();
expect(screen.getByText(/this entry changed since you opened it/i)).toBeTruthy();
});
- it("shows generic error when edit POST rejects with non-409", async () => {
- mockPost.mockRejectedValue(new Error("Server error"));
- renderTab();
+ it("shows generic error when edit save fails", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ mockPost.mockRejectedValue(new Error("save failed"));
+ render();
await flush();
- act(() => {
- screen.getByRole("button", { name: /advanced/i }).click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
await flush();
- act(() => {
- screen.getByText("edit-key").closest("button")?.click();
- });
+ fireEvent.click(screen.getByText("user_context"));
await flush();
- act(() => {
- screen
- .getAllByRole("button", { name: /^edit$/i })
- .find((b) => b.textContent === "Edit")
- ?.click();
- });
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
await flush();
- act(() => {
- screen.getByRole("button", { name: /save/i }).click();
- });
+ typeIn(screen.getByLabelText(/edit value for user_context/i) as HTMLElement, "x");
await flush();
- expect(screen.getByText("Server error")).toBeTruthy();
- });
-});
-
-describe("MemoryTab — expand/collapse entry", () => {
- beforeEach(() => {
- mockGet.mockResolvedValue([
- entry("entry-a", { data: "A" }),
- entry("entry-b", { data: "B" }),
- ]);
- });
-
- it("expands entry when clicked", async () => {
- renderTab();
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
- await flush();
- act(() => {
- screen.getByText("entry-a").closest("button")?.click();
- });
- await flush();
- // Expanded entry shows its JSON value
- expect(screen.getByText(/"data": "A"/)).toBeTruthy();
- });
-
- it("collapses entry when clicked again", async () => {
- renderTab();
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
- await flush();
- act(() => {
- screen.getByText("entry-a").closest("button")?.click();
- });
- await flush();
- act(() => {
- screen.getByText("entry-a").closest("button")?.click();
- });
- await flush();
- expect(screen.queryByText(/"data": "A"/)).toBeFalsy();
- });
-
- it("shows collapsed indicator ▶ for non-expanded entries", async () => {
- renderTab();
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
- await flush();
- expect(screen.getAllByText("▶").length).toBeGreaterThan(0);
- });
-
- it("shows expanded indicator ▼ for expanded entries", async () => {
- renderTab();
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
- await flush();
- act(() => {
- screen.getByText("entry-a").closest("button")?.click();
- });
- await flush();
- expect(screen.getAllByText("▼").length).toBeGreaterThan(0);
- });
-
- it("hides edit/delete buttons when entry is collapsed", async () => {
- renderTab();
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
- await flush();
- expect(screen.queryByRole("button", { name: /edit/i })).toBeFalsy();
- expect(screen.queryByRole("button", { name: /delete/i })).toBeFalsy();
- });
-
- it("shows edit/delete buttons when entry is expanded", async () => {
- renderTab();
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /advanced/i }));
- await flush();
- act(() => {
- screen.getByText("entry-a").closest("button")?.click();
- });
- await flush();
- expect(screen.getAllByRole("button", { name: /edit/i }).length).toBeGreaterThan(0);
- expect(screen.getAllByRole("button", { name: /delete/i }).length).toBeGreaterThan(0);
- });
-});
-
-describe("MemoryTab — Open Awareness button", () => {
- it("calls window.open with workspaceId in URL", async () => {
- mockGet.mockResolvedValueOnce([]);
- renderTab("my-ws");
- await flush();
- fireEvent.click(screen.getByRole("button", { name: /open/i }));
- await flush();
- expect(mockOpen).toHaveBeenCalled();
- const url = mockOpen.mock.calls[0][0];
- expect(url).toContain("workspaceId=my-ws");
+ act(() => { screen.getByRole("button", { name: /save/i }).click(); });
+ await flush();
+ expect(screen.getByText(/save failed/i)).toBeTruthy();
});
+
+ it("closes edit form when Cancel is clicked", async () => {
+ mockGet.mockResolvedValue([entry()]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /show/i }));
+ await flush();
+ fireEvent.click(screen.getByText("user_context"));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ expect(screen.getByLabelText(/edit value for user_context/i)).toBeTruthy();
+ act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
+ await flush();
+ await waitFor(() => {
+ expect(screen.queryByLabelText(/edit value for/i)).not.toBeTruthy();
+ });
+ });
+
+ // ── Refresh ────────────────────────────────────────────────────────────────
+
+ it("Refresh button calls loadMemory", async () => {
+ mockGet.mockResolvedValue([]);
+ render();
+ await flush();
+ mockGet.mockClear();
+ fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
+ await flush();
+ expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
+ });
+
});