Files
semantica/explorer/tests/markdownEditorInteraction.test.tsx
T
Yuang PengandSameer Kadam b92fed2d0a feat(explorer): add Markdown editor write path (#1349)
* feat(explorer): add Markdown editing write path

* fix(explorer): address markdown editor review findings

* feat(explorer): add Markdown editor write path

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-09-04 01:38:17 +05:30

640 lines
20 KiB
TypeScript

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("<!doctype html><html><body></body></html>", {
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(
<MarkdownContentViewer
content="Original"
resource={resource}
onApplied={(result) => { 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(<MarkdownContentViewer content="Original" resource={resource} />);
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(<MarkdownContentViewer content="Original" resource={resource} />);
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(
<MarkdownContentViewer content="node-1" resource={resource} />,
);
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(
<MarkdownContentViewer
content="node-2"
resource={{ kind: "context-node", id: "node-2" }}
/>,
);
await waitFor(() => assert.equal(
view.queryByRole("textbox", { name: "Markdown source" }),
null,
));
view.rerender(
<MarkdownContentViewer content="node-1" resource={resource} />,
);
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(
<MarkdownContentViewer
content="Original"
resource={resource}
onDirtyChange={(dirty) => 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(<MemoryWorkspace />);
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(<MemoryWorkspace />);
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<Response>((resolve) => {
resolveMem2 = resolve;
});
const mem3Response = new Promise<Response>((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(<MemoryWorkspace />);
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(<MemoryWorkspace />);
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(<MarkdownContentViewer content="Original" resource={resource} />);
// 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(
<MarkdownContentViewer
content="Original"
resource={resource}
onApplied={(result) => { 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)");
});