import assert from "node:assert/strict"; import test from "node:test"; import { JSDOM } from "jsdom"; import React from "react"; (globalThis as typeof globalThis & { React: typeof React }).React = React; const dom = new JSDOM("", { url: "http://localhost", }); Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, }); Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator, }); dom.window.confirm = () => true; // Testing Library and the components must load after the jsdom globals above. const { act, cleanup, fireEvent, render, waitFor } = await import( "@testing-library/react" ); const { MarkdownContentViewer } = await import( "../src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx" ); const { MemoryWorkspace } = await import( "../src/workspaces/MemoryWorkspace.tsx" ); test.afterEach(() => { cleanup(); dom.window.confirm = () => true; }); const resource = { kind: "context-node" as const, id: "node-1" }; const originalSource = "---\nid: node-1\ntype: Note\n---\n\nOriginal"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } test("Edit and Apply send canonical Markdown and publish the applied result", async () => { const requests: Array<{ url: string; init?: RequestInit }> = []; let appliedBody = ""; globalThis.fetch = async (input, init) => { requests.push({ url: String(input), init }); if (init?.method === "PUT") { return jsonResponse({ resource, source: originalSource.replace("Original", "Updated"), body: "Updated", revision: "sha256:updated", editable: true, changed: true, }); } return jsonResponse({ resource, source: originalSource, body: "Original", revision: "sha256:original", editable: true, }); }; const view = render( { appliedBody = result.body; }} />, ); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); fireEvent.input(textarea, { target: { value: originalSource.replace("Original", "Updated") }, }); fireEvent.click(view.getByRole("button", { name: "Apply" })); await waitFor(() => assert.equal(appliedBody, "Updated")); assert.deepEqual(requests.map(({ init }) => init?.method ?? "GET"), ["GET", "PUT"]); assert.equal( JSON.parse(String(requests[1].init?.body)).expected_revision, "sha256:original", ); assert.equal( view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"), "true", ); }); test("Cancel restores the previous view and never sends a PUT", async () => { const methods: string[] = []; globalThis.fetch = async (_input, init) => { methods.push(init?.method ?? "GET"); return jsonResponse({ resource, source: originalSource, body: "Original", revision: "sha256:original", editable: true, }); }; const view = render(); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); fireEvent.input(textarea, { target: { value: originalSource.replace("Original", "Draft") }, }); fireEvent.click(view.getByRole("button", { name: "Cancel" })); assert.deepEqual(methods, ["GET"]); assert.equal(view.queryByRole("textbox", { name: "Markdown source" }), null); assert.equal( view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"), "true", ); }); test("validation failures keep the draft visible for correction", async () => { globalThis.fetch = async (_input, init) => { if (init?.method === "PUT") { return jsonResponse({ detail: { code: "invalid_markdown_frontmatter", message: "Markdown frontmatter contains invalid YAML.", }, }, 422); } return jsonResponse({ resource, source: originalSource, body: "Original", revision: "sha256:original", editable: true, }); }; const view = render(); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); const invalidDraft = "---\nid: [\n---\n\nDraft"; fireEvent.input(textarea, { target: { value: invalidDraft } }); fireEvent.click(view.getByRole("button", { name: "Apply" })); const alert = await view.findByRole("alert"); assert.match(alert.textContent ?? "", /invalid YAML/); assert.equal( (view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement) .value, invalidDraft, ); }); test("resource changes discard the previous editor session", async () => { globalThis.fetch = async (input) => { const id = String(input).endsWith("node-2") ? "node-2" : "node-1"; return jsonResponse({ resource: { kind: "context-node", id }, source: `---\nid: ${id}\ntype: Note\n---\n\n${id}`, body: id, revision: `sha256:${id}`, editable: true, }); }; const view = render( , ); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); fireEvent.input(textarea, { target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` }, }); view.rerender( , ); await waitFor(() => assert.equal( view.queryByRole("textbox", { name: "Markdown source" }), null, )); view.rerender( , ); await waitFor(() => assert.equal( view.queryByRole("textbox", { name: "Markdown source" }), null, )); assert.ok(view.getByRole("button", { name: "Edit" })); }); test("unmounting a dirty editor clears the parent dirty guard", async () => { const dirtyStates: boolean[] = []; globalThis.fetch = async () => jsonResponse({ resource, source: originalSource, body: "Original", revision: "sha256:original", editable: true, }); const view = render( dirtyStates.push(dirty)} />, ); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); fireEvent.input(textarea, { target: { value: originalSource.replace("Original", "Draft") }, }); await waitFor(() => assert.equal(dirtyStates.at(-1), true)); view.unmount(); assert.equal(dirtyStates.at(-1), false); }); test("MemoryWorkspace protects a dirty memory draft when selection changes", async () => { const requestedUrls: string[] = []; globalThis.fetch = async (input) => { const url = String(input); requestedUrls.push(url); if (url.startsWith("/api/memories")) { return jsonResponse({ items: [ { id: "mem-1", type: "note", excerpt: "First", updated_at: null }, { id: "mem-2", type: "note", excerpt: "Second", updated_at: null }, ], total: 2, skip: 0, limit: 100, }); } const id = url.endsWith("mem-2") ? "mem-2" : "mem-1"; return jsonResponse({ resource: { kind: "agent-memory", id }, source: `---\nid: ${id}\ntype: note\n---\n\n${id}`, body: id, revision: `sha256:${id}`, editable: true, }); }; const view = render(); await view.findByText("Selected memory"); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); fireEvent.input(textarea, { target: { value: `${(textarea as HTMLTextAreaElement).value}\nDraft` }, }); dom.window.confirm = () => false; fireEvent.click(view.getByRole("button", { name: /mem-2/ })); assert.equal(requestedUrls.some((url) => url.endsWith("mem-2")), false); assert.equal(view.getByText("mem-1", { selector: "strong" }).textContent, "mem-1"); }); test("MemoryWorkspace loads memories beyond the first server page", async () => { const requestedUrls: string[] = []; const firstPage = Array.from({ length: 100 }, (_, index) => ({ id: `mem-${index + 1}`, type: "note", excerpt: `Memory ${index + 1}`, updated_at: null, })); globalThis.fetch = async (input) => { const url = String(input); requestedUrls.push(url); if (url === "/api/memories?skip=0&limit=100") { return jsonResponse({ items: firstPage, total: 101, skip: 0, limit: 100, }); } if (url === "/api/memories?skip=100&limit=100") { return jsonResponse({ items: [{ id: "mem-101", type: "note", excerpt: "Memory 101", updated_at: null, }], total: 101, skip: 100, limit: 100, }); } return jsonResponse({ resource: { kind: "agent-memory", id: "mem-1" }, source: "---\nid: mem-1\ntype: note\n---\n\nmem-1", body: "mem-1", revision: "sha256:mem-1", editable: true, }); }; const view = render(); await view.findByText("Selected memory"); fireEvent.click(view.getByRole("button", { name: "Load more memories" })); await view.findByRole("button", { name: /mem-101/ }); assert.ok(requestedUrls.includes("/api/memories?skip=100&limit=100")); assert.equal(view.getByText("101 of 101 loaded").textContent, "101 of 101 loaded"); }); test("MemoryWorkspace ignores stale selection responses", async () => { let resolveMem2: ((response: Response) => void) | undefined; let resolveMem3: ((response: Response) => void) | undefined; const mem2Response = new Promise((resolve) => { resolveMem2 = resolve; }); const mem3Response = new Promise((resolve) => { resolveMem3 = resolve; }); globalThis.fetch = async (input) => { const url = String(input); if (url.startsWith("/api/memories")) { return jsonResponse({ items: [ { id: "mem-1", type: "note", excerpt: "First", updated_at: null }, { id: "mem-2", type: "note", excerpt: "Second", updated_at: null }, { id: "mem-3", type: "note", excerpt: "Third", updated_at: null }, ], total: 3, skip: 0, limit: 100, }); } if (url.endsWith("mem-2")) return mem2Response; if (url.endsWith("mem-3")) return mem3Response; return jsonResponse({ resource: { kind: "agent-memory", id: "mem-1" }, source: "---\nid: mem-1\ntype: note\n---\n\nmem-1", body: "mem-1", revision: "sha256:mem-1", editable: true, }); }; const view = render(); await view.findByText("Selected memory"); fireEvent.click(view.getByRole("button", { name: /mem-2/ })); fireEvent.click(view.getByRole("button", { name: /mem-3/ })); await act(async () => { resolveMem3?.(jsonResponse({ resource: { kind: "agent-memory", id: "mem-3" }, source: "---\nid: mem-3\ntype: note\n---\n\nmem-3", body: "mem-3", revision: "sha256:mem-3", editable: true, })); await mem3Response; }); await waitFor(() => assert.equal( view.getByText("mem-3", { selector: "strong" }).textContent, "mem-3", )); await act(async () => { resolveMem2?.(jsonResponse({ resource: { kind: "agent-memory", id: "mem-2" }, source: "---\nid: mem-2\ntype: note\n---\n\nmem-2", body: "mem-2", revision: "sha256:mem-2", editable: true, })); await mem2Response; }); assert.equal( view.getByText("mem-3", { selector: "strong" }).textContent, "mem-3", ); }); test("MemoryWorkspace refreshes frontmatter summaries after apply", async () => { let listRequests = 0; globalThis.fetch = async (input, init) => { const url = String(input); if (url.startsWith("/api/memories")) { listRequests += 1; const saved = listRequests > 1; return jsonResponse({ items: [{ id: "mem-1", type: saved ? "decision" : "note", excerpt: saved ? "Updated memory" : "Original memory", updated_at: saved ? "2026-09-01T12:00:00+00:00" : null, }], total: 1, skip: 0, limit: 100, }); } if (init?.method === "PUT") { return jsonResponse({ resource: { kind: "agent-memory", id: "mem-1" }, source: "---\nid: mem-1\ntype: decision\n---\n\nUpdated memory", body: "Updated memory", revision: "sha256:updated", editable: true, changed: true, }); } return jsonResponse({ resource: { kind: "agent-memory", id: "mem-1" }, source: "---\nid: mem-1\ntype: note\n---\n\nOriginal memory", body: "Original memory", revision: "sha256:original", editable: true, }); }; const view = render(); await view.findByText("Selected memory"); fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); fireEvent.input(textarea, { target: { value: (textarea as HTMLTextAreaElement).value .replace("type: note", "type: decision") .replace("Original memory", "Updated memory"), }, }); fireEvent.click(view.getByRole("button", { name: "Apply" })); await view.findByText("decision"); assert.equal(listRequests, 2); assert.equal(view.getAllByText("Updated memory").length, 2); }); test("HTTP 409 conflict preserves draft and shows conflict error with reload option", async () => { // After a 409, the user's draft must be kept and a recovery path available. const requests: Array<{ method: string; body?: unknown }> = []; let fetchCount = 0; globalThis.fetch = async (input, init) => { fetchCount += 1; const method = init?.method ?? "GET"; let parsedBody: unknown = undefined; if (init?.body) { try { parsedBody = JSON.parse(String(init.body)); } catch { /* ignore */ } } requests.push({ method, body: parsedBody }); if (method === "PUT") { // First PUT returns 409 with current_revision return jsonResponse({ detail: { code: "markdown_revision_conflict", message: "This item changed after editing began. Reload the latest version before applying.", current_revision: "sha256:newer", }, }, 409); } // All GETs return the canonical document return jsonResponse({ resource, source: originalSource, body: "Original", revision: "sha256:original", editable: true, }); }; const view = render(); // Enter edit mode fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); const draftValue = originalSource.replace("Original", "My draft"); fireEvent.input(textarea, { target: { value: draftValue } }); // Apply → receives 409 fireEvent.click(view.getByRole("button", { name: "Apply" })); // Conflict error must appear const alert = await view.findByRole("alert"); assert.match( alert.textContent ?? "", /changed after editing|Reload/i, "conflict error message must be shown", ); // Draft must be preserved in the textarea const textareaAfterConflict = view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement; assert.equal(textareaAfterConflict.value, draftValue, "draft must be preserved after 409"); // A reload / recovery action must be available const reloadButton = view.queryByRole("button", { name: /reload latest/i }); assert.ok(reloadButton !== null, "a 'Reload latest' recovery button must be shown"); // Click reload — should re-fetch the latest canonical document await act(async () => { fireEvent.click(reloadButton!); }); // After reload the editor is re-initialized with the server's canonical source await waitFor(() => { const refreshedTextarea = view.queryByRole("textbox", { name: "Markdown source" }); assert.ok(refreshedTextarea !== null, "editor must still be open after reload"); assert.equal( (refreshedTextarea as HTMLTextAreaElement).value, originalSource, "editor must show the server canonical source after reload", ); }); // Reload must have triggered exactly one more GET const getCount = requests.filter((r) => r.method === "GET").length; assert.ok(getCount >= 2, "reload must issue a new GET to fetch the latest canonical document"); }); test("successful retry after 422 uses the original revision and persists changes", async () => { // After a 422 (validation failure), the baseRevision must remain valid so that // correcting the draft and re-applying succeeds without re-fetching the document. let putCallCount = 0; globalThis.fetch = async (_input, init) => { const method = init?.method ?? "GET"; if (method === "PUT") { putCallCount += 1; if (putCallCount === 1) { // First PUT: validation failure — resource is unchanged return jsonResponse({ detail: { code: "invalid_markdown_frontmatter", message: "Markdown frontmatter contains invalid YAML.", }, }, 422); } // Second PUT: success with the corrected Markdown const body = JSON.parse(String(init?.body ?? "{}")) as { markdown: string }; const correctedBody = body.markdown.includes("Corrected") ? "Corrected body" : "body"; return jsonResponse({ resource, source: originalSource.replace("Original", "Corrected"), body: correctedBody, revision: "sha256:after-retry", editable: true, changed: true, }); } return jsonResponse({ resource, source: originalSource, body: "Original", revision: "sha256:original", editable: true, }); }; let appliedRevision = ""; const view = render( { appliedRevision = result.revision; }} />, ); // Enter edit mode fireEvent.click(view.getByRole("button", { name: "Edit" })); const textarea = await view.findByRole("textbox", { name: "Markdown source" }); // First attempt: create an invalid draft const invalidDraft = "---\nid: [\n---\n\nInvalid body"; fireEvent.input(textarea, { target: { value: invalidDraft } }); fireEvent.click(view.getByRole("button", { name: "Apply" })); // 422 error appears, draft is preserved const alert = await view.findByRole("alert"); assert.match(alert.textContent ?? "", /invalid YAML/i); assert.equal( (view.getByRole("textbox", { name: "Markdown source" }) as HTMLTextAreaElement).value, invalidDraft, "invalid draft must be preserved after 422", ); // Correct the draft const correctedDraft = originalSource.replace("Original", "Corrected"); fireEvent.input(view.getByRole("textbox", { name: "Markdown source" }), { target: { value: correctedDraft }, }); // Apply is re-enabled (still dirty) const applyButton = view.getByRole("button", { name: "Apply" }); assert.equal( (applyButton as HTMLButtonElement).disabled, false, "Apply must be re-enabled after correcting the draft", ); // Second attempt: apply corrected draft fireEvent.click(applyButton); // Must succeed — server returns new revision await waitFor(() => assert.equal(appliedRevision, "sha256:after-retry")); // Editor returns to preview mode after successful save assert.equal( view.getByRole("tab", { name: "Preview" }).getAttribute("aria-selected"), "true", "editor must return to preview after successful retry", ); // Error is cleared assert.equal(view.queryByRole("alert"), null, "error banner must be cleared after success"); // Both PUT attempts were made — retry used original revision (no extra GET between attempts) assert.equal(putCallCount, 2, "exactly two PUT requests must be made (failed + successful retry)"); });