feat(mcp): serve embedded community MCP server at /mcp
Replace the removed enterprise EE MCP (private apps/server/src/ee submodule,
license-gated /mcp route) with our docmost-mcp, vendored as an isolated ESM
workspace package and served by the server over HTTP — no enterprise license.
Backend:
- Add packages/mcp (@docmost/mcp): vendored docmost-mcp refactored into a
side-effect-free createDocmostMcpServer() factory (38 tools preserved),
stdio entry kept in stdio.ts, Streamable-HTTP session manager in http.ts.
- Add apps/server McpModule: @Post/@Get/@Delete('mcp') (served at /mcp via the
existing global-prefix exclude), @SkipTransform + reply.hijack to bridge raw
Fastify req/res into the SDK transport. The module dynamically imports the
ESM-only package from CommonJS via a Function-indirected import resolved with
require.resolve + file:// URL. Gated by the workspace ai.mcp toggle, a
service-account (MCP_DOCMOST_EMAIL/PASSWORD/API_URL) and optional MCP_TOKEN;
per-session idle eviction (MCP_SESSION_IDLE_MS).
- Drop the enterprise license check on mcpEnabled in workspace.service.
- Dockerfile: copy packages/mcp into the production image.
- .env.example: document MCP_DOCMOST_*, MCP_TOKEN, MCP_SESSION_IDLE_MS.
Frontend:
- Recreate the community "AI & MCP" workspace-settings panel (mcp-settings.tsx):
admin-only toggle on settings.ai.mcp with optimistic update, copyable
${APP_URL}/mcp URL; wired into workspace-settings page. Reuses existing i18n.
Fixes:
- Pin packages/mcp tiptap deps to 3.20.4 (matching the client) and inline
getStyleProperty, preventing a duplicate @tiptap/core@3.26.1 from leaking into
the client editor via pnpm shamefully-hoist (was breaking apps/client tsc).
This commit is contained in:
126
packages/mcp/test/unit/collaboration.test.mjs
Normal file
126
packages/mcp/test/unit/collaboration.test.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildCollabWsUrl,
|
||||
markdownToProseMirror,
|
||||
} from "../../build/lib/collaboration.js";
|
||||
|
||||
/** Recursively find the first descendant node (or self) of the given type. */
|
||||
function find(node, type) {
|
||||
if (!node || typeof node !== "object") return null;
|
||||
if (node.type === type) return node;
|
||||
const kids = Array.isArray(node.content) ? node.content : [];
|
||||
for (const k of kids) {
|
||||
const r = find(k, type);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively collect every descendant node (and self) of the given type. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
const kids = Array.isArray(node.content) ? node.content : [];
|
||||
for (const k of kids) findAll(k, type, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Collect the set of mark types present anywhere in the document tree. */
|
||||
function collectMarkTypes(node, set = new Set()) {
|
||||
if (!node || typeof node !== "object") return set;
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const m of node.marks) set.add(m.type);
|
||||
}
|
||||
const kids = Array.isArray(node.content) ? node.content : [];
|
||||
for (const k of kids) collectMarkTypes(k, set);
|
||||
return set;
|
||||
}
|
||||
|
||||
test("buildCollabWsUrl: https + /api -> wss + /collab", () => {
|
||||
assert.equal(buildCollabWsUrl("https://h/api"), "wss://h/collab");
|
||||
});
|
||||
|
||||
test("buildCollabWsUrl: http (no /api) -> ws + /collab", () => {
|
||||
assert.equal(buildCollabWsUrl("http://h"), "ws://h/collab");
|
||||
});
|
||||
|
||||
test("buildCollabWsUrl: trailing slash on /api/ is handled", () => {
|
||||
assert.equal(buildCollabWsUrl("https://h/api/"), "wss://h/collab");
|
||||
});
|
||||
|
||||
test("buildCollabWsUrl: a base with trailing slash maps to /collab", () => {
|
||||
assert.equal(buildCollabWsUrl("https://h/"), "wss://h/collab");
|
||||
});
|
||||
|
||||
test("buildCollabWsUrl: query and hash on the base are dropped", () => {
|
||||
assert.equal(buildCollabWsUrl("https://h/api?foo=1#bar"), "wss://h/collab");
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: :::warning::: becomes a callout node typed warning", async () => {
|
||||
const doc = await markdownToProseMirror(":::warning\nhello\n:::");
|
||||
const callout = find(doc, "callout");
|
||||
assert.ok(callout, "expected a callout node");
|
||||
assert.equal(callout.attrs.type, "warning");
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: a ::: line inside a fenced code block is not a callout delimiter", async () => {
|
||||
const doc = await markdownToProseMirror("```\n:::warning\nx\n:::\n```");
|
||||
assert.equal(find(doc, "callout"), null, "code-fenced ::: must not open a callout");
|
||||
assert.ok(find(doc, "codeBlock"), "the fenced block should stay a codeBlock");
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: GFM checkbox list -> one taskList, two taskItems, no bulletList", async () => {
|
||||
const doc = await markdownToProseMirror("- [x] a\n- [ ] b");
|
||||
const taskLists = findAll(doc, "taskList");
|
||||
assert.equal(taskLists.length, 1, "expected exactly one taskList");
|
||||
const items = findAll(doc, "taskItem");
|
||||
assert.equal(items.length, 2, "expected two taskItems");
|
||||
assert.deepEqual(
|
||||
items.map((i) => i.attrs.checked),
|
||||
[true, false],
|
||||
);
|
||||
assert.equal(find(doc, "bulletList"), null, "no bulletList should remain");
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: numbered checklist -> one taskList, no orderedList (ol phantom regression)", async () => {
|
||||
const doc = await markdownToProseMirror("1. [x] a\n2. [ ] b");
|
||||
const taskLists = findAll(doc, "taskList");
|
||||
assert.equal(taskLists.length, 1, "expected exactly one taskList");
|
||||
assert.equal(
|
||||
find(doc, "orderedList"),
|
||||
null,
|
||||
"a numbered checklist must not leave a phantom orderedList",
|
||||
);
|
||||
assert.deepEqual(
|
||||
findAll(doc, "taskItem").map((i) => i.attrs.checked),
|
||||
[true, false],
|
||||
);
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: a plain numbered list stays an orderedList", async () => {
|
||||
const doc = await markdownToProseMirror("1. a\n2. b");
|
||||
assert.ok(find(doc, "orderedList"), "plain numbered list should be an orderedList");
|
||||
assert.equal(find(doc, "taskList"), null, "plain numbered list must not become a taskList");
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: mark/sub/sup produce highlight, subscript, superscript marks", async () => {
|
||||
const doc = await markdownToProseMirror("<mark>h</mark> <sub>x</sub> <sup>y</sup>");
|
||||
const marks = collectMarkTypes(doc);
|
||||
assert.ok(marks.has("highlight"), "expected a highlight mark");
|
||||
assert.ok(marks.has("subscript"), "expected a subscript mark");
|
||||
assert.ok(marks.has("superscript"), "expected a superscript mark");
|
||||
});
|
||||
|
||||
test("markdownToProseMirror: an aligned GFM table maps header alignment", async () => {
|
||||
const doc = await markdownToProseMirror(
|
||||
"| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |",
|
||||
);
|
||||
const headers = findAll(doc, "tableHeader");
|
||||
assert.equal(headers.length, 3, "expected three header cells");
|
||||
assert.deepEqual(
|
||||
headers.map((h) => h.attrs.align),
|
||||
["left", "center", "right"],
|
||||
);
|
||||
});
|
||||
136
packages/mcp/test/unit/diff.test.mjs
Normal file
136
packages/mcp/test/unit/diff.test.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { diffDocs } from "../../build/lib/diff.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
||||
const para = (...children) => ({ type: "paragraph", content: children });
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core diff: one inserted word
|
||||
// ---------------------------------------------------------------------------
|
||||
test("diffDocs detects a single inserted word", () => {
|
||||
const oldDoc = doc(para(t("Hello world")));
|
||||
const newDoc = doc(para(t("Hello brave world")));
|
||||
const r = diffDocs(oldDoc, newDoc);
|
||||
|
||||
assert.ok(r.summary.inserted > 0, "reports insertion length");
|
||||
assert.equal(r.summary.deleted, 0, "no deletions");
|
||||
const ins = r.changes.find((c) => c.op === "insert");
|
||||
assert.ok(ins, "has an insert change");
|
||||
assert.match(ins.text, /brave/);
|
||||
assert.match(r.markdown, /inserted/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core diff: one deleted block
|
||||
// ---------------------------------------------------------------------------
|
||||
test("diffDocs detects a deleted block", () => {
|
||||
const oldDoc = doc(para(t("keep this")), para(t("remove this block")));
|
||||
const newDoc = doc(para(t("keep this")));
|
||||
const r = diffDocs(oldDoc, newDoc);
|
||||
|
||||
assert.ok(r.summary.deleted > 0, "reports deletion length");
|
||||
const del = r.changes.find((c) => c.op === "delete");
|
||||
assert.ok(del, "has a delete change");
|
||||
assert.match(del.text, /remove this block/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrity counts
|
||||
// ---------------------------------------------------------------------------
|
||||
test("diffDocs reports integrity counts as [old,new] tuples", () => {
|
||||
const link = [{ type: "link", attrs: { href: "http://x" } }];
|
||||
const image = { type: "image", attrs: { src: "/api/files/a.png" } };
|
||||
const callout = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [para(t("note"))],
|
||||
};
|
||||
|
||||
const oldDoc = doc(
|
||||
para(t("a link", link)),
|
||||
image,
|
||||
callout,
|
||||
para(t("body with [1] and [2]")),
|
||||
);
|
||||
// new doc: drop the image, drop one footnote marker, keep link + callout.
|
||||
const newDoc = doc(
|
||||
para(t("a link", link)),
|
||||
callout,
|
||||
para(t("body with [1]")),
|
||||
);
|
||||
|
||||
const r = diffDocs(oldDoc, newDoc);
|
||||
assert.deepEqual(r.integrity.images, [1, 0]);
|
||||
assert.deepEqual(r.integrity.links, [1, 1]);
|
||||
assert.deepEqual(r.integrity.callouts, [1, 1]);
|
||||
assert.deepEqual(r.integrity.tables, [0, 0]);
|
||||
// footnote markers parsed in reading order from the body.
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[1, 2], [1]]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footnote markers stop at the notes heading
|
||||
// ---------------------------------------------------------------------------
|
||||
test("diffDocs footnote markers ignore the notes section", () => {
|
||||
const oldDoc = doc(
|
||||
para(t("body [1]")),
|
||||
{ type: "heading", attrs: { level: 2 }, content: [t("Примечания переводчика")] },
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
{ type: "listItem", content: [para(t("note [1] inside list"))] },
|
||||
],
|
||||
},
|
||||
);
|
||||
const r = diffDocs(oldDoc, oldDoc);
|
||||
// Only the body [1] is counted, not the [1] inside the notes list.
|
||||
assert.deepEqual(r.integrity.footnoteMarkers, [[1], [1]]);
|
||||
assert.equal(r.summary.inserted, 0);
|
||||
assert.equal(r.summary.deleted, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 3: links integrity counts UNIQUE links by href, not link-bearing runs.
|
||||
// A single link split across two runs (link+bold, then link) is one link.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("diffDocs counts a link split across two runs as one link", () => {
|
||||
const link = [{ type: "link", attrs: { href: "http://x" } }];
|
||||
const linkBold = [
|
||||
{ type: "link", attrs: { href: "http://x" } },
|
||||
{ type: "bold" },
|
||||
];
|
||||
// One logical link to http://x rendered as two adjacent runs.
|
||||
const splitDoc = doc(para(t("see ", linkBold), t("the link", link), t(" here")));
|
||||
// Same single href represented as a single run.
|
||||
const wholeDoc = doc(para(t("see the link", link), t(" here")));
|
||||
|
||||
const r = diffDocs(splitDoc, wholeDoc);
|
||||
// Unique-by-href: both sides have exactly one distinct link.
|
||||
assert.deepEqual(r.integrity.links, [1, 1]);
|
||||
});
|
||||
|
||||
test("diffDocs counts two distinct hrefs as two links", () => {
|
||||
const a = [{ type: "link", attrs: { href: "http://a" } }];
|
||||
const b = [{ type: "link", attrs: { href: "http://b" } }];
|
||||
const oldDoc = doc(para(t("one", a), t(" two", b)));
|
||||
// new doc drops the second link.
|
||||
const newDoc = doc(para(t("one", a), t(" two")));
|
||||
const r = diffDocs(oldDoc, newDoc);
|
||||
assert.deepEqual(r.integrity.links, [2, 1]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identical docs produce no changes
|
||||
// ---------------------------------------------------------------------------
|
||||
test("diffDocs on identical docs reports no changes", () => {
|
||||
const d = doc(para(t("unchanged")));
|
||||
const r = diffDocs(d, d);
|
||||
assert.equal(r.changes.length, 0);
|
||||
assert.equal(r.summary.blocksChanged, 0);
|
||||
});
|
||||
190
packages/mcp/test/unit/docmost-md-roundtrip.test.mjs
Normal file
190
packages/mcp/test/unit/docmost-md-roundtrip.test.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
serializeDocmostMarkdown,
|
||||
parseDocmostMarkdown,
|
||||
} from "../../build/lib/markdown-document.js";
|
||||
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
|
||||
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
|
||||
|
||||
/** Recursively find the first descendant node (or self) of the given type. */
|
||||
function find(node, type) {
|
||||
if (!node || typeof node !== "object") return null;
|
||||
if (node.type === type) return node;
|
||||
const kids = Array.isArray(node.content) ? node.content : [];
|
||||
for (const k of kids) {
|
||||
const r = find(k, type);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively collect every descendant node (and self) of the given type. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
const kids = Array.isArray(node.content) ? node.content : [];
|
||||
for (const k of kids) findAll(k, type, acc);
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Find the first text node carrying a mark of the given type. */
|
||||
function findTextWithMark(node, markType) {
|
||||
for (const t of findAll(node, "text")) {
|
||||
if (Array.isArray(t.marks) && t.marks.some((m) => m.type === markType)) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test("serialize/parse: meta and comments survive a round-trip; body recovered", () => {
|
||||
const meta = {
|
||||
version: 1,
|
||||
pageId: "p1",
|
||||
slugId: "s1",
|
||||
title: "Hello",
|
||||
spaceId: "sp1",
|
||||
parentPageId: null,
|
||||
};
|
||||
const body = "# Title\n\nSome **bold** body text.";
|
||||
const comments = [
|
||||
{ id: "c1", content: "a note", resolved: false },
|
||||
{ id: "c2", content: "another", resolved: true },
|
||||
];
|
||||
|
||||
const full = serializeDocmostMarkdown(meta, body, comments);
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
|
||||
assert.deepEqual(parsed.meta, meta);
|
||||
assert.deepEqual(parsed.comments, comments);
|
||||
assert.equal(parsed.body, body);
|
||||
});
|
||||
|
||||
test("serialize: a page with no comments still emits an empty comments block", () => {
|
||||
const full = serializeDocmostMarkdown({ version: 1 }, "body", []);
|
||||
assert.match(full, /<!--\s*docmost:comments\s*\n\[\]\n-->/);
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
assert.deepEqual(parsed.comments, []);
|
||||
});
|
||||
|
||||
test("parse: plain markdown with no blocks -> meta=null, comments=null, body=input", () => {
|
||||
const input = " # Just a heading\n\nplain body ";
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
assert.equal(parsed.meta, null);
|
||||
assert.equal(parsed.comments, null);
|
||||
assert.equal(parsed.body, input.trim());
|
||||
});
|
||||
|
||||
test("parse: tolerant to CRLF line endings", () => {
|
||||
const meta = { version: 1, pageId: "p9" };
|
||||
const body = "line one\n\nline two";
|
||||
const full = serializeDocmostMarkdown(meta, body, []).replace(/\n/g, "\r\n");
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
assert.deepEqual(parsed.meta, meta);
|
||||
assert.deepEqual(parsed.comments, []);
|
||||
assert.equal(parsed.body, body);
|
||||
});
|
||||
|
||||
test("parse: a malformed present meta block throws a clear error", () => {
|
||||
const bad = "<!-- docmost:meta\n{not valid json}\n-->\n\nbody\n";
|
||||
assert.throws(() => parseDocmostMarkdown(bad), /docmost:meta JSON/);
|
||||
});
|
||||
|
||||
test("parse: a literal comments-block in the body is left in the body when a real trailing block follows", () => {
|
||||
// The body documents the format (e.g. inside a fenced code block) AND there is
|
||||
// a real trailing comments block. Only the final, document-ending block is
|
||||
// metadata; the literal stays in the body verbatim.
|
||||
const meta = { version: 1, pageId: "p-literal" };
|
||||
const literal = "```\n<!-- docmost:comments\n[1]\n-->\n```";
|
||||
const body = `# Doc\n\nExample of the format:\n\n${literal}`;
|
||||
const realComments = [{ id: "c1", content: "real" }];
|
||||
|
||||
const full = serializeDocmostMarkdown(meta, body, realComments);
|
||||
const parsed = parseDocmostMarkdown(full);
|
||||
|
||||
// The REAL trailing comments are parsed.
|
||||
assert.deepEqual(parsed.comments, realComments);
|
||||
// The literal block text is still present in the recovered body.
|
||||
assert.ok(
|
||||
parsed.body.includes("<!-- docmost:comments\n[1]\n-->"),
|
||||
"expected the literal comments block to remain in the body",
|
||||
);
|
||||
assert.equal(parsed.body, body.trim());
|
||||
});
|
||||
|
||||
test("parse: a body-ending literal comments block (no real trailing block) is treated as the final block", () => {
|
||||
// Hand-written file whose ONLY `docmost:comments` opener is a literal that
|
||||
// also ends the document. Per the implementation, the final document-ending
|
||||
// block IS treated as metadata, so it is parsed and stripped from the body.
|
||||
const input = "# Doc\n\nsome text\n\n<!-- docmost:comments\n[1]\n-->\n";
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
assert.equal(parsed.meta, null);
|
||||
assert.deepEqual(parsed.comments, [1]);
|
||||
assert.equal(parsed.body, "# Doc\n\nsome text");
|
||||
});
|
||||
|
||||
test("parse: a literal comments block NOT ending the document stays entirely in the body", () => {
|
||||
// The literal opener/closer is followed by more body content, so it does not
|
||||
// end the document and is therefore left untouched in the body.
|
||||
const input =
|
||||
"# Doc\n\n<!-- docmost:comments\n[1]\n-->\n\nmore body after it\n";
|
||||
const parsed = parseDocmostMarkdown(input);
|
||||
assert.equal(parsed.meta, null);
|
||||
assert.equal(parsed.comments, null);
|
||||
assert.equal(parsed.body, input.trim());
|
||||
});
|
||||
|
||||
test("export emits comment anchors and they round-trip back to a comment mark", () => {
|
||||
// A small ProseMirror doc with a text run carrying a `comment` mark.
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "before " },
|
||||
{
|
||||
type: "text",
|
||||
text: "anchored",
|
||||
marks: [{ type: "comment", attrs: { commentId: "cm-123" } }],
|
||||
},
|
||||
{ type: "text", text: " after" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const body = convertProseMirrorToMarkdown(doc);
|
||||
assert.match(body, /data-comment-id="cm-123"/);
|
||||
|
||||
return markdownToProseMirror(body).then((rebuilt) => {
|
||||
const commented = findTextWithMark(rebuilt, "comment");
|
||||
assert.ok(commented, "expected a text node with a comment mark");
|
||||
const mark = commented.marks.find((m) => m.type === "comment");
|
||||
assert.equal(mark.attrs.commentId, "cm-123");
|
||||
});
|
||||
});
|
||||
|
||||
test("drawio round-trips through export and import", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "drawio",
|
||||
attrs: { src: "https://example/diagram.xml", attachmentId: "att-7" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const body = convertProseMirrorToMarkdown(doc);
|
||||
assert.match(body, /data-type="drawio"/);
|
||||
assert.match(body, /data-src="https:\/\/example\/diagram\.xml"/);
|
||||
|
||||
return markdownToProseMirror(body).then((rebuilt) => {
|
||||
const diagram = find(rebuilt, "drawio");
|
||||
assert.ok(diagram, "expected a drawio node after import");
|
||||
assert.equal(diagram.attrs.src, "https://example/diagram.xml");
|
||||
});
|
||||
});
|
||||
173
packages/mcp/test/unit/filters.test.mjs
Normal file
173
packages/mcp/test/unit/filters.test.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterComment, filterPage } from "../../build/lib/filters.js";
|
||||
|
||||
test("filterComment includes resolvedAt/resolvedById as null when absent", () => {
|
||||
const result = filterComment({
|
||||
id: "c1",
|
||||
pageId: "p1",
|
||||
content: "hello",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.resolvedAt, null);
|
||||
assert.equal(result.resolvedById, null);
|
||||
});
|
||||
|
||||
test("filterComment passes through resolvedAt/resolvedById when present", () => {
|
||||
const result = filterComment({
|
||||
id: "c1",
|
||||
pageId: "p1",
|
||||
content: "hello",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
resolvedAt: "2026-02-02T10:00:00.000Z",
|
||||
resolvedById: "user-42",
|
||||
});
|
||||
|
||||
assert.equal(result.resolvedAt, "2026-02-02T10:00:00.000Z");
|
||||
assert.equal(result.resolvedById, "user-42");
|
||||
});
|
||||
|
||||
test("filterComment still includes id/content/createdAt", () => {
|
||||
const result = filterComment({
|
||||
id: "c-id",
|
||||
pageId: "p1",
|
||||
content: "the body",
|
||||
createdAt: "2026-03-03T03:03:03.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.id, "c-id");
|
||||
assert.equal(result.content, "the body");
|
||||
assert.equal(result.createdAt, "2026-03-03T03:03:03.000Z");
|
||||
});
|
||||
|
||||
test("filterComment uses markdownContent override when provided", () => {
|
||||
const result = filterComment(
|
||||
{
|
||||
id: "c1",
|
||||
pageId: "p1",
|
||||
content: "raw json content",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
"**markdown** content",
|
||||
);
|
||||
|
||||
assert.equal(result.content, "**markdown** content");
|
||||
});
|
||||
|
||||
test("filterComment is null-safe on missing creator", () => {
|
||||
const result = filterComment({
|
||||
id: "c1",
|
||||
pageId: "p1",
|
||||
content: "hello",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
creatorId: "u1",
|
||||
// no `creator` object present
|
||||
});
|
||||
|
||||
assert.equal(result.creatorName, null);
|
||||
assert.equal(result.creatorId, "u1");
|
||||
});
|
||||
|
||||
test("filterComment reads creator.name when creator present", () => {
|
||||
const result = filterComment({
|
||||
id: "c1",
|
||||
pageId: "p1",
|
||||
content: "hello",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
creator: { name: "Alice" },
|
||||
});
|
||||
|
||||
assert.equal(result.creatorName, "Alice");
|
||||
});
|
||||
|
||||
test("filterComment defaults selection/type/parentCommentId/editedAt", () => {
|
||||
const result = filterComment({
|
||||
id: "c1",
|
||||
pageId: "p1",
|
||||
content: "hello",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.selection, null);
|
||||
assert.equal(result.type, "page");
|
||||
assert.equal(result.parentCommentId, null);
|
||||
assert.equal(result.editedAt, null);
|
||||
});
|
||||
|
||||
test("filterPage selects expected fields", () => {
|
||||
const result = filterPage({
|
||||
id: "page-1",
|
||||
slugId: "slug-1",
|
||||
title: "My Page",
|
||||
parentPageId: "parent-1",
|
||||
spaceId: "space-1",
|
||||
isLocked: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
deletedAt: null,
|
||||
// extra fields that must be dropped
|
||||
extraneous: "should not appear",
|
||||
content: "should be ignored when not passed as arg",
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
id: "page-1",
|
||||
slugId: "slug-1",
|
||||
title: "My Page",
|
||||
parentPageId: "parent-1",
|
||||
spaceId: "space-1",
|
||||
isLocked: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
deletedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterPage omits content key when content arg is not a string", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" });
|
||||
assert.equal("content" in result, false);
|
||||
});
|
||||
|
||||
test("filterPage includes content when arg is a string", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" }, "# Heading");
|
||||
assert.equal(result.content, "# Heading");
|
||||
});
|
||||
|
||||
test("filterPage includes content when arg is an empty string", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" }, "");
|
||||
assert.equal("content" in result, true);
|
||||
assert.equal(result.content, "");
|
||||
});
|
||||
|
||||
test("filterPage omits subpages when none provided", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" });
|
||||
assert.equal("subpages" in result, false);
|
||||
});
|
||||
|
||||
test("filterPage omits subpages when an empty array is provided", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" }, undefined, []);
|
||||
assert.equal("subpages" in result, false);
|
||||
});
|
||||
|
||||
test("filterPage maps subpages to id/title only", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" }, undefined, [
|
||||
{ id: "s1", title: "Sub One", extra: "drop" },
|
||||
{ id: "s2", title: "Sub Two" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.subpages, [
|
||||
{ id: "s1", title: "Sub One" },
|
||||
{ id: "s2", title: "Sub Two" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("filterPage includes both content and subpages together", () => {
|
||||
const result = filterPage({ id: "p1", title: "t" }, "body", [
|
||||
{ id: "s1", title: "Sub" },
|
||||
]);
|
||||
|
||||
assert.equal(result.content, "body");
|
||||
assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]);
|
||||
});
|
||||
173
packages/mcp/test/unit/json-edit.test.mjs
Normal file
173
packages/mcp/test/unit/json-edit.test.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { applyTextEdits } from "../../build/lib/json-edit.js";
|
||||
|
||||
// Helpers to build small ProseMirror docs.
|
||||
const textNode = (text, extra = {}) => ({ type: "text", text, ...extra });
|
||||
const paragraph = (...children) => ({ type: "paragraph", content: children });
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
|
||||
test("single-match replace preserves ids/marks and reports replacements===1", () => {
|
||||
const input = doc({
|
||||
type: "paragraph",
|
||||
attrs: { id: "para-1" },
|
||||
content: [
|
||||
textNode("Hello world", { marks: [{ type: "bold" }] }),
|
||||
],
|
||||
});
|
||||
|
||||
const { doc: out, results } = applyTextEdits(input, [
|
||||
{ find: "world", replace: "there" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(results, [{ find: "world", replacements: 1 }]);
|
||||
|
||||
const para = out.content[0];
|
||||
// Paragraph id attribute is preserved.
|
||||
assert.equal(para.attrs.id, "para-1");
|
||||
const tnode = para.content[0];
|
||||
// Text node marks are preserved.
|
||||
assert.deepEqual(tnode.marks, [{ type: "bold" }]);
|
||||
assert.equal(tnode.text, "Hello there");
|
||||
});
|
||||
|
||||
test("zero match throws not found", () => {
|
||||
const input = doc(paragraph(textNode("Hello world")));
|
||||
|
||||
assert.throws(
|
||||
() => applyTextEdits(input, [{ find: "absent", replace: "x" }]),
|
||||
/not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test("text split across two text nodes (one bold) throws spans-multiple-runs", () => {
|
||||
// "Hello world" is split: "Hello " (plain) + "world" (bold). No single text
|
||||
// node contains "Hello world", but the collected document text does.
|
||||
const input = doc(
|
||||
paragraph(
|
||||
textNode("Hello "),
|
||||
textNode("world", { marks: [{ type: "bold" }] }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => applyTextEdits(input, [{ find: "Hello world", replace: "x" }]),
|
||||
/spans/,
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-match without replaceAll throws matches", () => {
|
||||
// "ab" appears twice inside a single text node.
|
||||
const input = doc(paragraph(textNode("ab cd ab")));
|
||||
|
||||
assert.throws(
|
||||
() => applyTextEdits(input, [{ find: "ab", replace: "x" }]),
|
||||
/matches/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceAll replaces all occurrences", () => {
|
||||
const input = doc(
|
||||
paragraph(textNode("foo and foo")),
|
||||
paragraph(textNode("more foo")),
|
||||
);
|
||||
|
||||
const { doc: out, results } = applyTextEdits(input, [
|
||||
{ find: "foo", replace: "bar", replaceAll: true },
|
||||
]);
|
||||
|
||||
// 2 in the first paragraph, 1 in the second = 3 total.
|
||||
assert.deepEqual(results, [{ find: "foo", replacements: 3 }]);
|
||||
assert.equal(out.content[0].content[0].text, "bar and bar");
|
||||
assert.equal(out.content[1].content[0].text, "more bar");
|
||||
});
|
||||
|
||||
test("replacement containing $&, $1, $$ is inserted LITERALLY (regression)", () => {
|
||||
const input = doc(paragraph(textNode("token here")));
|
||||
|
||||
const literal = "price $& cost $1 dollars $$ end";
|
||||
const { doc: out } = applyTextEdits(input, [
|
||||
{ find: "token", replace: literal },
|
||||
]);
|
||||
|
||||
// The replacement must appear verbatim, NOT regex-expanded.
|
||||
assert.equal(out.content[0].content[0].text, `${literal} here`);
|
||||
// Be explicit that the find text was not re-injected via $&.
|
||||
assert.ok(out.content[0].content[0].text.includes("$&"));
|
||||
assert.ok(!out.content[0].content[0].text.includes("token"));
|
||||
});
|
||||
|
||||
test("$ patterns are inserted literally under replaceAll too", () => {
|
||||
const input = doc(paragraph(textNode("x and x")));
|
||||
|
||||
const { doc: out } = applyTextEdits(input, [
|
||||
{ find: "x", replace: "$&$1$$", replaceAll: true },
|
||||
]);
|
||||
|
||||
assert.equal(out.content[0].content[0].text, "$&$1$$ and $&$1$$");
|
||||
});
|
||||
|
||||
test("empty replacement prunes the emptied text node", () => {
|
||||
// A paragraph whose only text node becomes empty: the node must be pruned.
|
||||
const input = doc(
|
||||
paragraph(
|
||||
textNode("DELETE", { marks: [{ type: "italic" }] }),
|
||||
textNode(" kept"),
|
||||
),
|
||||
);
|
||||
|
||||
const { doc: out, results } = applyTextEdits(input, [
|
||||
{ find: "DELETE", replace: "" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(results, [{ find: "DELETE", replacements: 1 }]);
|
||||
const para = out.content[0];
|
||||
// The emptied first text node is gone; only the " kept" node remains.
|
||||
assert.equal(para.content.length, 1);
|
||||
assert.equal(para.content[0].text, " kept");
|
||||
});
|
||||
|
||||
test("multi-edit array applied in order", () => {
|
||||
const input = doc(paragraph(textNode("alpha beta")));
|
||||
|
||||
const { doc: out, results } = applyTextEdits(input, [
|
||||
{ find: "alpha", replace: "ALPHA" },
|
||||
{ find: "beta", replace: "BETA" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{ find: "alpha", replacements: 1 },
|
||||
{ find: "beta", replacements: 1 },
|
||||
]);
|
||||
assert.equal(out.content[0].content[0].text, "ALPHA BETA");
|
||||
});
|
||||
|
||||
test("second edit can target text produced by the first (ordered application)", () => {
|
||||
const input = doc(paragraph(textNode("one")));
|
||||
|
||||
const { doc: out, results } = applyTextEdits(input, [
|
||||
{ find: "one", replace: "two" },
|
||||
{ find: "two", replace: "three" },
|
||||
]);
|
||||
|
||||
assert.deepEqual(results, [
|
||||
{ find: "one", replacements: 1 },
|
||||
{ find: "two", replacements: 1 },
|
||||
]);
|
||||
assert.equal(out.content[0].content[0].text, "three");
|
||||
});
|
||||
|
||||
test("input doc is not mutated", () => {
|
||||
const input = doc(paragraph(textNode("immutable source")));
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
|
||||
const { doc: out } = applyTextEdits(input, [
|
||||
{ find: "immutable", replace: "changed" },
|
||||
]);
|
||||
|
||||
// Original is untouched; the returned doc is a distinct object.
|
||||
assert.deepEqual(input, snapshot);
|
||||
assert.notEqual(out, input);
|
||||
assert.equal(out.content[0].content[0].text, "changed source");
|
||||
});
|
||||
151
packages/mcp/test/unit/markdown-converter.test.mjs
Normal file
151
packages/mcp/test/unit/markdown-converter.test.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
|
||||
|
||||
// ProseMirror builders.
|
||||
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
|
||||
const paragraph = (...content) => ({ type: "paragraph", content });
|
||||
const doc = (...content) => ({ type: "doc", content });
|
||||
const listItem = (...content) => ({ type: "listItem", content });
|
||||
const bulletList = (...items) => ({ type: "bulletList", content: items });
|
||||
const orderedList = (...items) => ({ type: "orderedList", content: items });
|
||||
|
||||
test("nested bulletList with 3 children keeps all children indented under the parent", () => {
|
||||
const input = doc(
|
||||
bulletList(
|
||||
listItem(
|
||||
paragraph(text("Parent")),
|
||||
bulletList(
|
||||
listItem(paragraph(text("A"))),
|
||||
listItem(paragraph(text("B"))),
|
||||
listItem(paragraph(text("C"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
"- Parent\n - A\n - B\n - C",
|
||||
);
|
||||
});
|
||||
|
||||
test("nested list under an ordered item indents 3 spaces", () => {
|
||||
const input = doc(
|
||||
orderedList(
|
||||
listItem(
|
||||
paragraph(text("Parent")),
|
||||
bulletList(listItem(paragraph(text("Child")))),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
"1. Parent\n - Child",
|
||||
);
|
||||
});
|
||||
|
||||
test("link with title -> [t](url \"title\")", () => {
|
||||
const input = doc(
|
||||
paragraph(
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "https://example.com", title: "the title" } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'[click](https://example.com "the title")',
|
||||
);
|
||||
});
|
||||
|
||||
test("hardBreak -> trailing two-spaces+newline", () => {
|
||||
const input = doc(
|
||||
paragraph(text("line1"), { type: "hardBreak" }, text("line2")),
|
||||
);
|
||||
|
||||
assert.equal(convertProseMirrorToMarkdown(input), "line1 \nline2");
|
||||
});
|
||||
|
||||
test("table cell with two block children joined by a space (and a pipe escaped)", () => {
|
||||
const input = doc({
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
content: [paragraph(text("a|b")), paragraph(text("c"))],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Single-column header row + separator. The cell joins its two paragraphs
|
||||
// with a space ("a|b c") then escapes the pipe -> "a\|b c".
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
"| a\\|b c |\n| --- |",
|
||||
);
|
||||
});
|
||||
|
||||
test("code block trailing newline trimmed", () => {
|
||||
const input = doc({
|
||||
type: "codeBlock",
|
||||
attrs: { language: "js" },
|
||||
content: [text("const a = 1;\n")],
|
||||
});
|
||||
|
||||
// The single trailing newline inside the code is trimmed; fences add one.
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
"```js\nconst a = 1;\n```",
|
||||
);
|
||||
});
|
||||
|
||||
test("textAlign value: delimiting double-quote escaped (attribute-safe, idempotent; < > left literal/inert)", () => {
|
||||
const input = doc({
|
||||
type: "paragraph",
|
||||
attrs: { textAlign: 'right"><b' },
|
||||
content: [text("body")],
|
||||
});
|
||||
|
||||
// Attribute values escape only & and " so the value cannot break out of the
|
||||
// quoted attribute. < and > are left literal: parse5/jsdom does NOT decode
|
||||
// </> inside attribute values, so escaping them would corrupt the value
|
||||
// and accumulate on every round-trip. The literal < > are inert inside quotes.
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'<div align="right"><b">body</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test("highlight color: delimiting double-quote escaped (attribute-safe; < > inert, and import sanitizes the color)", () => {
|
||||
const input = doc(
|
||||
paragraph(
|
||||
text("hi", [{ type: "highlight", attrs: { color: 'red"><script' } }]),
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
'<mark style="background-color: red"><script">hi</mark>',
|
||||
);
|
||||
});
|
||||
|
||||
test("empty task item still emits its marker", () => {
|
||||
const input = doc({
|
||||
type: "taskList",
|
||||
content: [
|
||||
{ type: "taskItem", attrs: { checked: false }, content: [] },
|
||||
{ type: "taskItem", attrs: { checked: true }, content: [] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(convertProseMirrorToMarkdown(input), "- [ ]\n- [x]");
|
||||
});
|
||||
301
packages/mcp/test/unit/node-ops-table.test.mjs
Normal file
301
packages/mcp/test/unit/node-ops-table.test.mjs
Normal file
@@ -0,0 +1,301 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
insertNodeRelative,
|
||||
sanitizeForYjs,
|
||||
findUnstorableAttr,
|
||||
} from "../../build/lib/node-ops.js";
|
||||
|
||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
const para = (id, ...children) => ({
|
||||
type: "paragraph",
|
||||
attrs: { id },
|
||||
content: children,
|
||||
});
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// A table cell holding a single paragraph.
|
||||
const cell = (id, innerPara) => ({
|
||||
type: "tableCell",
|
||||
attrs: { id },
|
||||
content: [innerPara],
|
||||
});
|
||||
const row = (id, ...cells) => ({
|
||||
type: "tableRow",
|
||||
attrs: { id },
|
||||
content: cells,
|
||||
});
|
||||
const table = (id, ...rows) => ({
|
||||
type: "table",
|
||||
attrs: { id },
|
||||
content: rows,
|
||||
});
|
||||
|
||||
// A 2x2 table: rows r1/r2, cells c1..c4, each cell holds a paragraph p1..p4.
|
||||
const make2x2Table = () =>
|
||||
doc(
|
||||
table(
|
||||
"t1",
|
||||
row("r1", cell("c1", para("p1", textNode("A1"))), cell("c2", para("p2", textNode("A2")))),
|
||||
row("r2", cell("c3", para("p3", textNode("B1"))), cell("c4", para("p4", textNode("B2")))),
|
||||
),
|
||||
);
|
||||
|
||||
const freshRow = () => row("rNEW", cell("cNEW", para("pNEW", textNode("NEW"))));
|
||||
const freshCell = () => cell("cNEW", para("pNEW", textNode("NEW")));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitizeForYjs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("sanitizeForYjs strips undefined node-attr keys, preserves null/false/0/''", () => {
|
||||
const input = doc({
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
id: "p-1",
|
||||
gone: undefined,
|
||||
keptNull: null,
|
||||
keptFalse: false,
|
||||
keptZero: 0,
|
||||
keptEmpty: "",
|
||||
},
|
||||
content: [textNode("x")],
|
||||
});
|
||||
const out = sanitizeForYjs(input);
|
||||
const attrs = out.content[0].attrs;
|
||||
assert.equal("gone" in attrs, false);
|
||||
assert.equal("keptNull" in attrs, true);
|
||||
assert.equal(attrs.keptNull, null);
|
||||
assert.equal(attrs.keptFalse, false);
|
||||
assert.equal(attrs.keptZero, 0);
|
||||
assert.equal(attrs.keptEmpty, "");
|
||||
// Input must not be mutated.
|
||||
assert.equal("gone" in input.content[0].attrs, true);
|
||||
});
|
||||
|
||||
test("sanitizeForYjs strips undefined mark-attr keys, preserves falsy values", () => {
|
||||
const input = doc({
|
||||
type: "paragraph",
|
||||
attrs: { id: "p-1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "x",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: { href: "", target: undefined, rel: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = sanitizeForYjs(input);
|
||||
const markAttrs = out.content[0].content[0].marks[0].attrs;
|
||||
assert.equal("target" in markAttrs, false);
|
||||
assert.equal(markAttrs.href, "");
|
||||
assert.equal(markAttrs.rel, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findUnstorableAttr
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("findUnstorableAttr returns a path for an undefined node attr", () => {
|
||||
const input = doc(
|
||||
para("p-0", textNode("ok")),
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { id: "p-1", indent: undefined },
|
||||
content: [textNode("y")],
|
||||
},
|
||||
);
|
||||
const hit = findUnstorableAttr(input);
|
||||
assert.equal(hit, "content[1].attrs.indent (undefined)");
|
||||
});
|
||||
|
||||
test("findUnstorableAttr finds an unstorable mark attr", () => {
|
||||
const input = doc({
|
||||
type: "paragraph",
|
||||
attrs: { id: "p-1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "x",
|
||||
marks: [{ type: "link", attrs: { href: () => {} } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const hit = findUnstorableAttr(input);
|
||||
assert.equal(hit, "content[0].content[0].marks[0].attrs.href (function)");
|
||||
});
|
||||
|
||||
test("findUnstorableAttr returns null for a clean doc", () => {
|
||||
const input = doc(para("p-1", textNode("clean")));
|
||||
assert.equal(findUnstorableAttr(input), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insertNodeRelative — table-structure-aware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("insertNodeRelative inserting a tableRow anchored on a paragraph INSIDE a cell appends a sibling row to the table", () => {
|
||||
const input = make2x2Table();
|
||||
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
|
||||
position: "after",
|
||||
anchorNodeId: "p4", // paragraph inside last cell of the last row
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
const tbl = out.content[0];
|
||||
// table.content length +1 (the row is a direct child of the table).
|
||||
assert.equal(tbl.content.length, 3);
|
||||
// The new row is a direct child of the table, NOT nested inside a cell.
|
||||
const newRow = tbl.content[2];
|
||||
assert.equal(newRow.type, "tableRow");
|
||||
assert.equal(newRow.attrs.id, "rNEW");
|
||||
// Existing rows' cells are intact.
|
||||
assert.deepEqual(
|
||||
tbl.content[0].content.map((c) => c.attrs.id),
|
||||
["c1", "c2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
tbl.content[1].content.map((c) => c.attrs.id),
|
||||
["c3", "c4"],
|
||||
);
|
||||
// Assert the new row is NOT nested inside any existing cell.
|
||||
for (const r of [tbl.content[0], tbl.content[1]]) {
|
||||
for (const c of r.content) {
|
||||
const ids = (c.content || []).map((n) => n.attrs?.id);
|
||||
assert.equal(ids.includes("rNEW"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("insertNodeRelative before/after place the new row at the correct index relative to the enclosing row", () => {
|
||||
// "before" the first row.
|
||||
{
|
||||
const input = make2x2Table();
|
||||
const { doc: out } = insertNodeRelative(input, freshRow(), {
|
||||
position: "before",
|
||||
anchorNodeId: "p1", // paragraph in first row
|
||||
});
|
||||
assert.deepEqual(
|
||||
out.content[0].content.map((r) => r.attrs.id),
|
||||
["rNEW", "r1", "r2"],
|
||||
);
|
||||
}
|
||||
// "after" the first row.
|
||||
{
|
||||
const input = make2x2Table();
|
||||
const { doc: out } = insertNodeRelative(input, freshRow(), {
|
||||
position: "after",
|
||||
anchorNodeId: "p1", // paragraph in first row
|
||||
});
|
||||
assert.deepEqual(
|
||||
out.content[0].content.map((r) => r.attrs.id),
|
||||
["r1", "rNEW", "r2"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("insertNodeRelative inserting a tableCell anchored inside a cell adds it to the enclosing row", () => {
|
||||
const input = make2x2Table();
|
||||
const { doc: out, inserted } = insertNodeRelative(input, freshCell(), {
|
||||
position: "after",
|
||||
anchorNodeId: "p1", // paragraph inside first cell of first row
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
// The cell is spliced into the enclosing row (r1) after c1.
|
||||
assert.deepEqual(
|
||||
out.content[0].content[0].content.map((c) => c.attrs.id),
|
||||
["c1", "cNEW", "c2"],
|
||||
);
|
||||
// The other row is untouched.
|
||||
assert.deepEqual(
|
||||
out.content[0].content[1].content.map((c) => c.attrs.id),
|
||||
["c3", "c4"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative inserting a tableRow with an anchor NOT inside a table throws", () => {
|
||||
const input = doc(para("p-1", textNode("plain")));
|
||||
assert.throws(
|
||||
() =>
|
||||
insertNodeRelative(input, freshRow(), {
|
||||
position: "after",
|
||||
anchorNodeId: "p-1",
|
||||
}),
|
||||
/not inside a table/,
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative append + tableRow throws", () => {
|
||||
const input = make2x2Table();
|
||||
assert.throws(
|
||||
() => insertNodeRelative(input, freshRow(), { position: "append" }),
|
||||
/cannot append a tableRow at the top level/,
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative structural insert with unresolved anchor returns inserted:false (no throw)", () => {
|
||||
const input = make2x2Table();
|
||||
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
|
||||
position: "after",
|
||||
anchorNodeId: "does-not-exist",
|
||||
});
|
||||
assert.equal(inserted, false);
|
||||
assert.deepEqual(out, input);
|
||||
});
|
||||
|
||||
test("insertNodeRelative tableRow by anchorText resolving to the table block appends within the table", () => {
|
||||
const input = make2x2Table();
|
||||
// anchorText "A1" lives in the first cell; the matched top-level block is the
|
||||
// table itself, so the row appends at the end of the table.
|
||||
const { doc: out, inserted } = insertNodeRelative(input, freshRow(), {
|
||||
position: "after",
|
||||
anchorText: "A1",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content[0].content.map((r) => r.attrs.id),
|
||||
["r1", "r2", "rNEW"],
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: a normal (non-structural) paragraph insert is unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("insertNodeRelative regression: normal paragraph before/after a top-level block behaves as before", () => {
|
||||
const before = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
||||
{
|
||||
const { doc: out, inserted } = insertNodeRelative(
|
||||
before,
|
||||
para("new", textNode("NEW")),
|
||||
{ position: "before", anchorNodeId: "p-2" },
|
||||
);
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "new", "p-2"],
|
||||
);
|
||||
}
|
||||
{
|
||||
const snap = snapshot(before);
|
||||
const { doc: out, inserted } = insertNodeRelative(
|
||||
before,
|
||||
para("new", textNode("NEW")),
|
||||
{ position: "after", anchorNodeId: "p-1" },
|
||||
);
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "new", "p-2"],
|
||||
);
|
||||
// Input not mutated.
|
||||
assert.deepEqual(before, snap);
|
||||
}
|
||||
});
|
||||
402
packages/mcp/test/unit/node-ops.test.mjs
Normal file
402
packages/mcp/test/unit/node-ops.test.mjs
Normal file
@@ -0,0 +1,402 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
blockPlainText,
|
||||
replaceNodeById,
|
||||
deleteNodeById,
|
||||
insertNodeRelative,
|
||||
} from "../../build/lib/node-ops.js";
|
||||
|
||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
const para = (id, ...children) => ({
|
||||
type: "paragraph",
|
||||
attrs: { id },
|
||||
content: children,
|
||||
});
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// A callout / table-cell wraps its children in `content`, just like any other
|
||||
// block, so recursion reaches a paragraph nested inside it.
|
||||
const callout = (id, ...children) => ({
|
||||
type: "callout",
|
||||
attrs: { id, type: "info" },
|
||||
content: children,
|
||||
});
|
||||
const tableDoc = (innerPara) =>
|
||||
doc({
|
||||
type: "table",
|
||||
attrs: { id: "table-1" },
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
attrs: { id: "row-1" },
|
||||
content: [
|
||||
{
|
||||
type: "tableCell",
|
||||
attrs: { id: "cell-1" },
|
||||
content: [innerPara],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// blockPlainText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("blockPlainText concatenates nested text", () => {
|
||||
const node = {
|
||||
type: "callout",
|
||||
content: [
|
||||
para("p-1", textNode("Hello "), textNode("world")),
|
||||
para("p-2", textNode("!")),
|
||||
],
|
||||
};
|
||||
assert.equal(blockPlainText(node), "Hello world!");
|
||||
});
|
||||
|
||||
test("blockPlainText returns '' for nullish / non-object", () => {
|
||||
assert.equal(blockPlainText(null), "");
|
||||
assert.equal(blockPlainText(undefined), "");
|
||||
assert.equal(blockPlainText("just a string"), "");
|
||||
});
|
||||
|
||||
test("blockPlainText reads a bare text node", () => {
|
||||
assert.equal(blockPlainText(textNode("solo")), "solo");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// replaceNodeById
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("replaceNodeById replaces the matching block and leaves others, count===1", () => {
|
||||
const input = doc(
|
||||
para("p-1", textNode("one")),
|
||||
para("p-2", textNode("two")),
|
||||
para("p-3", textNode("three")),
|
||||
);
|
||||
const newNode = para("p-2", textNode("REPLACED"));
|
||||
|
||||
const { doc: out, replaced } = replaceNodeById(input, "p-2", newNode);
|
||||
|
||||
assert.equal(replaced, 1);
|
||||
// Target replaced.
|
||||
assert.equal(out.content[1].content[0].text, "REPLACED");
|
||||
// Siblings untouched (text and ids).
|
||||
assert.equal(out.content[0].content[0].text, "one");
|
||||
assert.equal(out.content[2].content[0].text, "three");
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "p-2", "p-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("replaceNodeById on no-match returns replaced===0 and does not throw", () => {
|
||||
const input = doc(para("p-1", textNode("one")));
|
||||
const { doc: out, replaced } = replaceNodeById(
|
||||
input,
|
||||
"missing",
|
||||
para("x", textNode("x")),
|
||||
);
|
||||
assert.equal(replaced, 0);
|
||||
// Document content is preserved.
|
||||
assert.equal(out.content[0].content[0].text, "one");
|
||||
});
|
||||
|
||||
test("replaceNodeById replaces EVERY node sharing the id (count reflects all)", () => {
|
||||
const input = doc(
|
||||
para("dup", textNode("a")),
|
||||
para("dup", textNode("b")),
|
||||
para("keep", textNode("c")),
|
||||
);
|
||||
const { doc: out, replaced } = replaceNodeById(
|
||||
input,
|
||||
"dup",
|
||||
para("dup", textNode("NEW")),
|
||||
);
|
||||
assert.equal(replaced, 2);
|
||||
assert.equal(out.content[0].content[0].text, "NEW");
|
||||
assert.equal(out.content[1].content[0].text, "NEW");
|
||||
assert.equal(out.content[2].content[0].text, "c");
|
||||
// The two replacements must not share a reference (deep clone per match).
|
||||
assert.notEqual(out.content[0], out.content[1]);
|
||||
});
|
||||
|
||||
test("replaceNodeById reaches a node nested inside a callout", () => {
|
||||
const input = doc(callout("c-1", para("inner", textNode("old"))));
|
||||
const { doc: out, replaced } = replaceNodeById(
|
||||
input,
|
||||
"inner",
|
||||
para("inner", textNode("new")),
|
||||
);
|
||||
assert.equal(replaced, 1);
|
||||
assert.equal(out.content[0].content[0].content[0].text, "new");
|
||||
});
|
||||
|
||||
test("replaceNodeById reaches a node nested inside a table cell", () => {
|
||||
const input = tableDoc(para("deep", textNode("before")));
|
||||
const { doc: out, replaced } = replaceNodeById(
|
||||
input,
|
||||
"deep",
|
||||
para("deep", textNode("after")),
|
||||
);
|
||||
assert.equal(replaced, 1);
|
||||
const cellPara = out.content[0].content[0].content[0].content[0];
|
||||
assert.equal(cellPara.content[0].text, "after");
|
||||
});
|
||||
|
||||
test("replaceNodeById does NOT mutate input (deep-equal snapshot)", () => {
|
||||
const input = doc(
|
||||
para("p-1", textNode("one")),
|
||||
callout("c-1", para("inner", textNode("old"))),
|
||||
);
|
||||
const snap = snapshot(input);
|
||||
const { doc: out } = replaceNodeById(
|
||||
input,
|
||||
"inner",
|
||||
para("inner", textNode("changed")),
|
||||
);
|
||||
assert.deepEqual(input, snap);
|
||||
assert.notEqual(out, input);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteNodeById
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("deleteNodeById removes the block and reports deleted===1", () => {
|
||||
const input = doc(
|
||||
para("p-1", textNode("one")),
|
||||
para("p-2", textNode("two")),
|
||||
para("p-3", textNode("three")),
|
||||
);
|
||||
const { doc: out, deleted } = deleteNodeById(input, "p-2");
|
||||
assert.equal(deleted, 1);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "p-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteNodeById on no-match returns deleted===0 and leaves content", () => {
|
||||
const input = doc(para("p-1", textNode("one")));
|
||||
const { doc: out, deleted } = deleteNodeById(input, "missing");
|
||||
assert.equal(deleted, 0);
|
||||
assert.equal(out.content.length, 1);
|
||||
});
|
||||
|
||||
test("deleteNodeById removes a node nested inside a callout", () => {
|
||||
const input = doc(
|
||||
callout("c-1", para("inner", textNode("x")), para("keep", textNode("y"))),
|
||||
);
|
||||
const { doc: out, deleted } = deleteNodeById(input, "inner");
|
||||
assert.equal(deleted, 1);
|
||||
assert.deepEqual(
|
||||
out.content[0].content.map((n) => n.attrs.id),
|
||||
["keep"],
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteNodeById removes EVERY node sharing the id", () => {
|
||||
const input = doc(
|
||||
para("dup", textNode("a")),
|
||||
para("keep", textNode("b")),
|
||||
para("dup", textNode("c")),
|
||||
);
|
||||
const { doc: out, deleted } = deleteNodeById(input, "dup");
|
||||
assert.equal(deleted, 2);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["keep"],
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteNodeById does NOT mutate input (deep-equal snapshot)", () => {
|
||||
const input = doc(
|
||||
para("p-1", textNode("one")),
|
||||
para("p-2", textNode("two")),
|
||||
);
|
||||
const snap = snapshot(input);
|
||||
const { doc: out } = deleteNodeById(input, "p-2");
|
||||
assert.deepEqual(input, snap);
|
||||
assert.notEqual(out, input);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insertNodeRelative
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("insertNodeRelative before by anchorNodeId", () => {
|
||||
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "before",
|
||||
anchorNodeId: "p-2",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "new", "p-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative after by anchorNodeId", () => {
|
||||
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "after",
|
||||
anchorNodeId: "p-1",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "new", "p-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative before/after by anchorNodeId reaches a nested sibling", () => {
|
||||
const input = doc(
|
||||
callout("c-1", para("a", textNode("a")), para("b", textNode("b"))),
|
||||
);
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "after",
|
||||
anchorNodeId: "a",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
// Inserted as a sibling inside the callout's content array.
|
||||
assert.deepEqual(
|
||||
out.content[0].content.map((n) => n.attrs.id),
|
||||
["a", "new", "b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative before by anchorText (top-level)", () => {
|
||||
const input = doc(
|
||||
para("p-1", textNode("alpha")),
|
||||
para("p-2", textNode("beta")),
|
||||
);
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "before",
|
||||
anchorText: "beta",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "new", "p-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative after by anchorText (top-level)", () => {
|
||||
const input = doc(
|
||||
para("p-1", textNode("alpha")),
|
||||
para("p-2", textNode("beta")),
|
||||
);
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "after",
|
||||
anchorText: "alpha",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "new", "p-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative anchorText scans TOP-LEVEL blocks via recursive plain text", () => {
|
||||
// anchorText matches the FIRST top-level block whose (recursive) blockPlainText
|
||||
// includes the string. "deeptext" lives nested in a top-level callout, so the
|
||||
// callout itself is the matched top-level block and the node lands as its
|
||||
// sibling at the top level (not inside the callout).
|
||||
const input = doc(
|
||||
callout("c-1", para("inner", textNode("deeptext"))),
|
||||
para("p-2", textNode("tail")),
|
||||
);
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "after",
|
||||
anchorText: "deeptext",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["c-1", "new", "p-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative anchorText does NOT match text only present below top level when no top-level block contains it", () => {
|
||||
// The only block whose plain text includes "lonely" is a paragraph nested two
|
||||
// levels deep, but the top-level scan still sees it through the callout's
|
||||
// recursive plain text. To prove the scan is TOP-LEVEL (parent-array) only,
|
||||
// assert the insertion happens at the top level beside the callout, never
|
||||
// inside it.
|
||||
const input = doc(callout("c-1", para("inner", textNode("lonely word"))));
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "before",
|
||||
anchorText: "lonely",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
// Inserted at the top level (siblings of the callout), not into the callout.
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["new", "c-1"],
|
||||
);
|
||||
// The callout's own children are untouched.
|
||||
assert.deepEqual(
|
||||
out.content[1].content.map((n) => n.attrs.id),
|
||||
["inner"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative append pushes the node at the end of top-level content", () => {
|
||||
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "append",
|
||||
});
|
||||
assert.equal(inserted, true);
|
||||
assert.deepEqual(
|
||||
out.content.map((n) => n.attrs.id),
|
||||
["p-1", "p-2", "new"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertNodeRelative inserted===false when anchorNodeId missing", () => {
|
||||
const input = doc(para("p-1", textNode("one")));
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
||||
position: "after",
|
||||
anchorNodeId: "nope",
|
||||
});
|
||||
assert.equal(inserted, false);
|
||||
assert.deepEqual(out, input);
|
||||
});
|
||||
|
||||
test("insertNodeRelative inserted===false when anchorText missing", () => {
|
||||
const input = doc(para("p-1", textNode("one")));
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { inserted } = insertNodeRelative(input, node, {
|
||||
position: "before",
|
||||
anchorText: "nomatch",
|
||||
});
|
||||
assert.equal(inserted, false);
|
||||
});
|
||||
|
||||
test("insertNodeRelative does NOT mutate input (deep-equal snapshot)", () => {
|
||||
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
||||
const snap = snapshot(input);
|
||||
const node = para("new", textNode("NEW"));
|
||||
const { doc: out } = insertNodeRelative(input, node, {
|
||||
position: "after",
|
||||
anchorNodeId: "p-1",
|
||||
});
|
||||
assert.deepEqual(input, snap);
|
||||
assert.notEqual(out, input);
|
||||
});
|
||||
109
packages/mcp/test/unit/outline.test.mjs
Normal file
109
packages/mcp/test/unit/outline.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js";
|
||||
|
||||
// Helpers to build the small fixture doc.
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
const paragraph = (id, text) => ({
|
||||
type: "paragraph",
|
||||
attrs: { id },
|
||||
content: [textNode(text)],
|
||||
});
|
||||
// A table cell holds a paragraph; cells/rows/table carry NO attrs.id.
|
||||
const cell = (text) => ({
|
||||
type: "tableCell",
|
||||
content: [{ type: "paragraph", content: [textNode(text)] }],
|
||||
});
|
||||
const row = (...texts) => ({
|
||||
type: "tableRow",
|
||||
content: texts.map(cell),
|
||||
});
|
||||
const listItem = (text) => ({
|
||||
type: "listItem",
|
||||
content: [{ type: "paragraph", content: [textNode(text)] }],
|
||||
});
|
||||
|
||||
// A long paragraph to exercise truncation (>100 chars).
|
||||
const longText = "x".repeat(150);
|
||||
|
||||
const buildDoc = () => ({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { id: "h1", level: 2 }, content: [textNode("Title")] },
|
||||
paragraph("p1", longText),
|
||||
{
|
||||
type: "table",
|
||||
content: [row("A", "B", "C"), row("1", "2", "3")],
|
||||
},
|
||||
{
|
||||
type: "bulletList",
|
||||
attrs: { id: "list1" },
|
||||
content: [listItem("one"), listItem("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test("buildOutline returns one compact entry per top-level block", () => {
|
||||
const outline = buildOutline(buildDoc());
|
||||
assert.equal(outline.length, 4);
|
||||
|
||||
// Heading: level + id + firstText.
|
||||
assert.equal(outline[0].type, "heading");
|
||||
assert.equal(outline[0].level, 2);
|
||||
assert.equal(outline[0].id, "h1");
|
||||
assert.equal(outline[0].firstText, "Title");
|
||||
|
||||
// Long paragraph text is truncated to 100 chars + ellipsis.
|
||||
assert.equal(outline[1].id, "p1");
|
||||
assert.equal(outline[1].firstText, "x".repeat(100) + "…");
|
||||
assert.equal(outline[1].firstText.length, 101);
|
||||
|
||||
// Table: rows/cols/header from the first row; no id on the table itself.
|
||||
assert.equal(outline[2].type, "table");
|
||||
assert.equal(outline[2].rows, 2);
|
||||
assert.equal(outline[2].cols, 3);
|
||||
assert.deepEqual(outline[2].header, ["A", "B", "C"]);
|
||||
assert.equal(outline[2].id, null);
|
||||
|
||||
// List: item count.
|
||||
assert.equal(outline[3].type, "bulletList");
|
||||
assert.equal(outline[3].items, 2);
|
||||
});
|
||||
|
||||
test("buildOutline is null-safe", () => {
|
||||
assert.deepEqual(buildOutline(undefined), []);
|
||||
assert.deepEqual(buildOutline({ type: "doc" }), []);
|
||||
assert.deepEqual(buildOutline(42), []);
|
||||
});
|
||||
|
||||
test("getNodeByRef resolves a block id to its node and path", () => {
|
||||
const doc = buildDoc();
|
||||
const hit = getNodeByRef(doc, "h1");
|
||||
assert.ok(hit);
|
||||
assert.equal(hit.type, "heading");
|
||||
assert.deepEqual(hit.path, [0]);
|
||||
assert.equal(hit.node.attrs.id, "h1");
|
||||
});
|
||||
|
||||
test("getNodeByRef resolves #<index> to a top-level block (table)", () => {
|
||||
const doc = buildDoc();
|
||||
const hit = getNodeByRef(doc, "#2");
|
||||
assert.ok(hit);
|
||||
assert.equal(hit.type, "table");
|
||||
assert.deepEqual(hit.path, [2]);
|
||||
});
|
||||
|
||||
test("getNodeByRef returns null for an unknown ref", () => {
|
||||
assert.equal(getNodeByRef(buildDoc(), "nope"), null);
|
||||
});
|
||||
|
||||
test("getNodeByRef returns a clone (mutating it does not change the input)", () => {
|
||||
const doc = buildDoc();
|
||||
const hit = getNodeByRef(doc, "h1");
|
||||
hit.node.attrs.id = "MUTATED";
|
||||
hit.node.content[0].text = "changed";
|
||||
// Original doc is untouched.
|
||||
assert.equal(doc.content[0].attrs.id, "h1");
|
||||
assert.equal(doc.content[0].content[0].text, "Title");
|
||||
});
|
||||
153
packages/mcp/test/unit/page-lock.test.mjs
Normal file
153
packages/mcp/test/unit/page-lock.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { withPageLock } from "../../build/lib/page-lock.js";
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test("two ops on the same pageId run strictly sequentially (no overlap)", async () => {
|
||||
const events = [];
|
||||
const pageId = "same-page";
|
||||
|
||||
const p1 = withPageLock(pageId, async () => {
|
||||
events.push("start-1");
|
||||
await delay(40);
|
||||
events.push("end-1");
|
||||
return "r1";
|
||||
});
|
||||
|
||||
// Queue the second op while the first is still running.
|
||||
const p2 = withPageLock(pageId, async () => {
|
||||
events.push("start-2");
|
||||
await delay(10);
|
||||
events.push("end-2");
|
||||
return "r2";
|
||||
});
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
|
||||
assert.equal(r1, "r1");
|
||||
assert.equal(r2, "r2");
|
||||
// First op must fully finish before the second one begins.
|
||||
assert.deepEqual(events, ["start-1", "end-1", "start-2", "end-2"]);
|
||||
});
|
||||
|
||||
test("same pageId ordering holds for many queued ops", async () => {
|
||||
const pageId = "ordered-page";
|
||||
const order = [];
|
||||
const active = { count: 0, maxConcurrent: 0 };
|
||||
|
||||
const ops = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ops.push(
|
||||
withPageLock(pageId, async () => {
|
||||
active.count += 1;
|
||||
active.maxConcurrent = Math.max(active.maxConcurrent, active.count);
|
||||
order.push(i);
|
||||
await delay(5);
|
||||
active.count -= 1;
|
||||
return i;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(ops);
|
||||
|
||||
assert.deepEqual(results, [0, 1, 2, 3, 4, 5]);
|
||||
assert.deepEqual(order, [0, 1, 2, 3, 4, 5]);
|
||||
// Strictly sequential: never more than one op running at a time.
|
||||
assert.equal(active.maxConcurrent, 1);
|
||||
});
|
||||
|
||||
test("a rejecting op does not poison the chain for the same page", async () => {
|
||||
const pageId = "poison-page";
|
||||
const events = [];
|
||||
|
||||
const failing = withPageLock(pageId, async () => {
|
||||
events.push("fail-start");
|
||||
await delay(20);
|
||||
events.push("fail-throw");
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
// The caller of the failing op must still see the rejection.
|
||||
await assert.rejects(failing, /boom/);
|
||||
|
||||
const following = withPageLock(pageId, async () => {
|
||||
events.push("next-run");
|
||||
await delay(5);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
const result = await following;
|
||||
|
||||
assert.equal(result, "ok");
|
||||
// The next op ran after the failing one settled and was not blocked by it.
|
||||
assert.deepEqual(events, ["fail-start", "fail-throw", "next-run"]);
|
||||
});
|
||||
|
||||
test("failing op queued before a success both resolve/reject correctly", async () => {
|
||||
const pageId = "poison-page-2";
|
||||
const order = [];
|
||||
|
||||
const failing = withPageLock(pageId, async () => {
|
||||
order.push("fail");
|
||||
await delay(20);
|
||||
throw new Error("nope");
|
||||
});
|
||||
|
||||
const ok = withPageLock(pageId, async () => {
|
||||
order.push("ok");
|
||||
await delay(5);
|
||||
return 123;
|
||||
});
|
||||
|
||||
await assert.rejects(failing, /nope/);
|
||||
assert.equal(await ok, 123);
|
||||
// The failing op still ran first (it was queued first), then the success.
|
||||
assert.deepEqual(order, ["fail", "ok"]);
|
||||
});
|
||||
|
||||
test("ops on different pageIds run concurrently (overlap)", async () => {
|
||||
const events = [];
|
||||
|
||||
const pA = withPageLock("page-A", async () => {
|
||||
events.push("A-start");
|
||||
await delay(40);
|
||||
events.push("A-end");
|
||||
return "A";
|
||||
});
|
||||
|
||||
const pB = withPageLock("page-B", async () => {
|
||||
events.push("B-start");
|
||||
await delay(10);
|
||||
events.push("B-end");
|
||||
return "B";
|
||||
});
|
||||
|
||||
const [rA, rB] = await Promise.all([pA, pB]);
|
||||
|
||||
assert.equal(rA, "A");
|
||||
assert.equal(rB, "B");
|
||||
// B starts before A finishes (concurrent), and B finishes before A.
|
||||
assert.deepEqual(events, ["A-start", "B-start", "B-end", "A-end"]);
|
||||
});
|
||||
|
||||
test("no functional leak: many sequential ops on same page keep working", async () => {
|
||||
const pageId = "leak-page";
|
||||
|
||||
// Run a long series of fully sequential ops (each awaited before the next is
|
||||
// queued) so the internal map entry is created and dropped repeatedly.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const value = await withPageLock(pageId, async () => {
|
||||
await delay(1);
|
||||
return i;
|
||||
});
|
||||
assert.equal(value, i);
|
||||
}
|
||||
|
||||
// After the chain has drained, a brand new op on the same page still works,
|
||||
// confirming the entry was not left in a broken state.
|
||||
const final = await withPageLock(pageId, async () => "still-works");
|
||||
assert.equal(final, "still-works");
|
||||
});
|
||||
149
packages/mcp/test/unit/roundtrip.test.mjs
Normal file
149
packages/mcp/test/unit/roundtrip.test.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
// Round-trip regression tests: PM -> markdown -> PM must preserve rich nodes.
|
||||
// These lock in the converter/schema fixes (math, mention, attachment, columns,
|
||||
// nested blocks, text color) and the attribute-escaping idempotency fix.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
|
||||
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
|
||||
|
||||
const doc = (...content) => ({ type: "doc", content });
|
||||
const para = (...content) => ({ type: "paragraph", content });
|
||||
const text = (t, marks) => (marks ? { type: "text", text: t, marks } : { type: "text", text: t });
|
||||
|
||||
// Recursively collect nodes of a given type.
|
||||
const findNodes = (node, type, acc = []) => {
|
||||
if (!node) return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
for (const c of node.content || []) findNodes(c, type, acc);
|
||||
return acc;
|
||||
};
|
||||
// Recursively collect the set of mark types present.
|
||||
const markTypes = (node, acc = new Set()) => {
|
||||
if (!node) return acc;
|
||||
for (const m of node.marks || []) acc.add(m.type);
|
||||
for (const c of node.content || []) markTypes(c, acc);
|
||||
return acc;
|
||||
};
|
||||
const roundtrip = async (pmDoc) => markdownToProseMirror(convertProseMirrorToMarkdown(pmDoc));
|
||||
|
||||
test("round-trip: text color (textStyle mark) survives", async () => {
|
||||
const input = doc(para(text("colored", [{ type: "textStyle", attrs: { color: "red" } }])));
|
||||
const out = await roundtrip(input);
|
||||
const ts = findNodes(out, "text").flatMap((n) => n.marks || []).filter((m) => m.type === "textStyle");
|
||||
assert.ok(ts.length >= 1, "textStyle mark should survive");
|
||||
assert.equal(ts[0].attrs?.color, "red");
|
||||
});
|
||||
|
||||
test("round-trip: mathInline with '<' survives and is idempotent", async () => {
|
||||
const input = doc(para(text("x"), { type: "mathInline", attrs: { text: "a < b \\leq c" } }));
|
||||
const md1 = convertProseMirrorToMarkdown(input);
|
||||
const md2 = convertProseMirrorToMarkdown(await markdownToProseMirror(md1));
|
||||
assert.equal(md1, md2, "markdown must be idempotent across a round-trip (no escape accumulation)");
|
||||
const out = await markdownToProseMirror(md1);
|
||||
const math = findNodes(out, "mathInline");
|
||||
assert.equal(math.length, 1, "mathInline node should survive");
|
||||
assert.equal(math[0].attrs?.text, "a < b \\leq c", "LaTeX (incl. '<') preserved exactly");
|
||||
});
|
||||
|
||||
test("round-trip: mathBlock survives", async () => {
|
||||
const input = doc({ type: "mathBlock", attrs: { text: "E = mc^2" } });
|
||||
const out = await roundtrip(input);
|
||||
const math = findNodes(out, "mathBlock");
|
||||
assert.equal(math.length, 1);
|
||||
assert.equal(math[0].attrs?.text, "E = mc^2");
|
||||
});
|
||||
|
||||
test("round-trip: mention node survives (not flattened to @text)", async () => {
|
||||
const input = doc(para(text("hi "), { type: "mention", attrs: { id: "u1", label: "Alice", entityType: "user", entityId: "u1" } }));
|
||||
const out = await roundtrip(input);
|
||||
assert.equal(findNodes(out, "mention").length, 1, "mention node should survive");
|
||||
});
|
||||
|
||||
test("round-trip: attachment node survives with url + name", async () => {
|
||||
const input = doc({ type: "attachment", attrs: { url: "/api/files/x/report.pdf", name: "report.pdf", mime: "application/pdf" } });
|
||||
const out = await roundtrip(input);
|
||||
const att = findNodes(out, "attachment");
|
||||
assert.equal(att.length, 1, "attachment node should survive");
|
||||
assert.equal(att[0].attrs?.url, "/api/files/x/report.pdf");
|
||||
assert.equal(att[0].attrs?.name, "report.pdf");
|
||||
});
|
||||
|
||||
test("round-trip: image inside a column survives as an image node (not literal markdown)", async () => {
|
||||
const input = doc({
|
||||
type: "columns",
|
||||
content: [
|
||||
{ type: "column", content: [para(text("left")), { type: "image", attrs: { src: "/api/files/a/p.png", alt: "pic" } }] },
|
||||
{ type: "column", content: [para(text("right"))] },
|
||||
],
|
||||
});
|
||||
const out = await roundtrip(input);
|
||||
assert.equal(findNodes(out, "image").length, 1, "image inside a column must survive");
|
||||
// and it must NOT leak as literal markdown text
|
||||
assert.ok(!JSON.stringify(out).includes("![pic]"), "image must not become literal markdown text");
|
||||
});
|
||||
|
||||
test("round-trip: blockquote inside a column survives as a blockquote node", async () => {
|
||||
const input = doc({
|
||||
type: "columns",
|
||||
content: [
|
||||
{ type: "column", content: [{ type: "blockquote", content: [para(text("quoted"))] }] },
|
||||
{ type: "column", content: [para(text("r"))] },
|
||||
],
|
||||
});
|
||||
const out = await roundtrip(input);
|
||||
assert.equal(findNodes(out, "blockquote").length, 1, "blockquote inside a column must survive");
|
||||
});
|
||||
|
||||
test("round-trip: table cell with colspan>1 keeps the grid (HTML fallback)", async () => {
|
||||
const cell = (t, attrs = {}) => ({ type: "tableCell", attrs, content: [para(text(t))] });
|
||||
const header = (t) => ({ type: "tableHeader", attrs: {}, content: [para(text(t))] });
|
||||
const input = doc({
|
||||
type: "table",
|
||||
content: [
|
||||
{ type: "tableRow", content: [header("A"), header("B")] },
|
||||
{ type: "tableRow", content: [cell("wide", { colspan: 2 })] },
|
||||
],
|
||||
});
|
||||
const out = await roundtrip(input);
|
||||
const tables = findNodes(out, "table");
|
||||
assert.equal(tables.length, 1, "table should survive");
|
||||
const spanned = findNodes(out, "tableCell").find((c) => (c.attrs?.colspan ?? 1) > 1);
|
||||
assert.ok(spanned, "colspan>1 cell should be preserved via the HTML fallback");
|
||||
});
|
||||
|
||||
test("import: an unsafe highlight color (raw data-color) is sanitized to null (no style breakout)", async () => {
|
||||
// data-color is read verbatim (no CSSOM isolation), so it is the real
|
||||
// injection surface; a value with quotes/semicolons must be clamped to null.
|
||||
const out = await markdownToProseMirror('<mark data-color="red"; background:url(x)">hi</mark>');
|
||||
const hl = findNodes(out, "text").flatMap((n) => n.marks || []).filter((m) => m.type === "highlight");
|
||||
assert.ok(hl.length >= 1, "highlight mark present");
|
||||
assert.equal(hl[0].attrs?.color ?? null, null, "unsafe color must be clamped to null");
|
||||
});
|
||||
|
||||
test("import: a safe highlight color is preserved", async () => {
|
||||
const out = await markdownToProseMirror('<mark style="background-color: #ff0000">hi</mark>');
|
||||
const hl = findNodes(out, "text").flatMap((n) => n.marks || []).filter((m) => m.type === "highlight");
|
||||
assert.ok(hl.length >= 1);
|
||||
assert.equal(hl[0].attrs?.color, "#ff0000");
|
||||
});
|
||||
|
||||
test("round-trip: attribute value with an apostrophe is idempotent (no & accumulation)", async () => {
|
||||
const input = doc({ type: "attachment", attrs: { url: "/api/files/x/o'brien's file.pdf", name: "o'brien's file.pdf" } });
|
||||
const md1 = convertProseMirrorToMarkdown(input);
|
||||
const md2 = convertProseMirrorToMarkdown(await markdownToProseMirror(md1));
|
||||
assert.equal(md1, md2, "apostrophe in an attribute value must not accumulate escapes across round-trips");
|
||||
const att = findNodes(await markdownToProseMirror(md1), "attachment");
|
||||
assert.equal(att.length, 1);
|
||||
assert.equal(att[0].attrs?.name, "o'brien's file.pdf", "apostrophe preserved verbatim");
|
||||
});
|
||||
|
||||
test("import: a colored span that is also a comment keeps the comment mark", async () => {
|
||||
const out = await markdownToProseMirror('<span data-comment-id="c1" style="color: red">x</span>');
|
||||
const marks = findNodes(out, "text").flatMap((n) => n.marks || []).map((m) => m.type);
|
||||
assert.ok(marks.includes("comment"), "comment mark must survive (textStyle must not steal the span)");
|
||||
});
|
||||
|
||||
test("import: a colored mention span keeps the mention node", async () => {
|
||||
const out = await markdownToProseMirror('<span data-type="mention" data-id="u1" data-label="Alice" style="color: blue">@Alice</span>');
|
||||
assert.equal(findNodes(out, "mention").length, 1, "mention node must survive a colored span");
|
||||
});
|
||||
77
packages/mcp/test/unit/schema.test.mjs
Normal file
77
packages/mcp/test/unit/schema.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
docmostExtensions,
|
||||
clampCalloutType,
|
||||
} from "../../build/lib/docmost-schema.js";
|
||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||
|
||||
test("clampCalloutType: a known type passes through", () => {
|
||||
assert.equal(clampCalloutType("warning"), "warning");
|
||||
});
|
||||
|
||||
test("clampCalloutType: an uppercase known type folds to lower case", () => {
|
||||
assert.equal(clampCalloutType("WARNING"), "warning");
|
||||
assert.equal(clampCalloutType("Info"), "info");
|
||||
});
|
||||
|
||||
test("clampCalloutType: an unknown type falls back to info", () => {
|
||||
assert.equal(clampCalloutType("bogus"), "info");
|
||||
});
|
||||
|
||||
test("clampCalloutType: null and undefined fall back to info", () => {
|
||||
assert.equal(clampCalloutType(null), "info");
|
||||
assert.equal(clampCalloutType(undefined), "info");
|
||||
});
|
||||
|
||||
// Minimal-doc builders for the toYdoc acceptance loop.
|
||||
const text = (t) => ({ type: "text", text: t });
|
||||
const paragraph = (inline) => ({ type: "paragraph", content: inline });
|
||||
const docOf = (...content) => ({ type: "doc", content });
|
||||
|
||||
// Each entry is a minimal valid doc for one Docmost node type. Inline atoms
|
||||
// (mention, mathInline) and inline-capable nodes go inside a paragraph; block
|
||||
// atoms and block containers go at the top level.
|
||||
const cases = {
|
||||
mention: docOf(
|
||||
paragraph([{ type: "mention", attrs: { id: "u1", label: "Bob" } }]),
|
||||
),
|
||||
mathInline: docOf(paragraph([{ type: "mathInline", attrs: { text: "x^2" } }])),
|
||||
mathBlock: docOf({ type: "mathBlock", attrs: { text: "x^2" } }),
|
||||
details: docOf({
|
||||
type: "details",
|
||||
content: [
|
||||
{ type: "detailsSummary", content: [text("Summary")] },
|
||||
{ type: "detailsContent", content: [paragraph([text("body")])] },
|
||||
],
|
||||
}),
|
||||
attachment: docOf({
|
||||
type: "attachment",
|
||||
attrs: { url: "http://x/f.zip", name: "f.zip" },
|
||||
}),
|
||||
video: docOf({ type: "video", attrs: { src: "http://x/v.mp4" } }),
|
||||
youtube: docOf({ type: "youtube", attrs: { src: "http://y/watch" } }),
|
||||
embed: docOf({ type: "embed", attrs: { src: "http://e", provider: "iframe" } }),
|
||||
drawio: docOf({ type: "drawio", attrs: { src: "http://d" } }),
|
||||
excalidraw: docOf({ type: "excalidraw", attrs: { src: "http://e" } }),
|
||||
columns: docOf({
|
||||
type: "columns",
|
||||
content: [
|
||||
{ type: "column", content: [paragraph([text("c1")])] },
|
||||
{ type: "column", content: [paragraph([text("c2")])] },
|
||||
],
|
||||
}),
|
||||
subpages: docOf({ type: "subpages" }),
|
||||
audio: docOf({ type: "audio", attrs: { src: "http://a.mp3" } }),
|
||||
pdf: docOf({ type: "pdf", attrs: { src: "http://p.pdf" } }),
|
||||
pageBreak: docOf({ type: "pageBreak" }),
|
||||
};
|
||||
|
||||
for (const [name, doc] of Object.entries(cases)) {
|
||||
test(`toYdoc accepts a ${name} node without throwing`, () => {
|
||||
assert.doesNotThrow(() => {
|
||||
TiptapTransformer.toYdoc(doc, "default", docmostExtensions);
|
||||
});
|
||||
});
|
||||
}
|
||||
338
packages/mcp/test/unit/table-ops.test.mjs
Normal file
338
packages/mcp/test/unit/table-ops.test.mjs
Normal file
@@ -0,0 +1,338 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
} from "../../build/lib/node-ops.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
||||
// cell does. A cell holds a single plain-text paragraph.
|
||||
// ---------------------------------------------------------------------------
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
const para = (id, text) => ({
|
||||
type: "paragraph",
|
||||
attrs: { id, indent: 0 },
|
||||
content: text ? [textNode(text)] : [],
|
||||
});
|
||||
const cell = (paraId, text, colwidth) => ({
|
||||
type: "tableCell",
|
||||
attrs: { colspan: 1, rowspan: 1, ...(colwidth ? { colwidth } : {}) },
|
||||
content: [para(paraId, text)],
|
||||
});
|
||||
const row = (...cells) => ({ type: "tableRow", content: cells });
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// Heading at index 0, a 3x3 table at index 1.
|
||||
// Header row "A"/"B"/"C" with colwidths [120]/[200]/[150]; two data rows.
|
||||
const makeDoc = () =>
|
||||
doc(
|
||||
{ type: "heading", attrs: { id: "h1", level: 1 }, content: [textNode("Title")] },
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
row(
|
||||
cell("hpA", "A", [120]),
|
||||
cell("hpB", "B", [200]),
|
||||
cell("hpC", "C", [150]),
|
||||
),
|
||||
row(cell("p10", "r1c0"), cell("p11", "r1c1"), cell("p12", "r1c2")),
|
||||
row(cell("p20", "r2c0"), cell("p21", "r2c1"), cell("p22", "r2c2")),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Gather every attrs.id present anywhere in a doc.
|
||||
const allIds = (node, acc = new Set()) => {
|
||||
if (node && typeof node === "object" && !Array.isArray(node)) {
|
||||
if (node.attrs && typeof node.attrs.id === "string") acc.add(node.attrs.id);
|
||||
if (Array.isArray(node.content)) node.content.forEach((c) => allIds(c, acc));
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// readTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("readTable('#1') returns the 3x3 matrix, cell ids, and path", () => {
|
||||
const t = readTable(makeDoc(), "#1");
|
||||
assert.ok(t);
|
||||
assert.equal(t.rows, 3);
|
||||
assert.equal(t.cols, 3);
|
||||
assert.deepEqual(t.cells, [
|
||||
["A", "B", "C"],
|
||||
["r1c0", "r1c1", "r1c2"],
|
||||
["r2c0", "r2c1", "r2c2"],
|
||||
]);
|
||||
assert.deepEqual(t.cellIds, [
|
||||
["hpA", "hpB", "hpC"],
|
||||
["p10", "p11", "p12"],
|
||||
["p20", "p21", "p22"],
|
||||
]);
|
||||
assert.deepEqual(t.path, [1]);
|
||||
});
|
||||
|
||||
test("readTable(<cell paragraph id>) resolves the enclosing table", () => {
|
||||
const t = readTable(makeDoc(), "p21"); // a paragraph inside a data cell
|
||||
assert.ok(t);
|
||||
assert.equal(t.rows, 3);
|
||||
assert.equal(t.cols, 3);
|
||||
assert.deepEqual(t.path, [1]);
|
||||
});
|
||||
|
||||
test("readTable on a non-table block / unknown ref returns null", () => {
|
||||
assert.equal(readTable(makeDoc(), "#0"), null); // heading, not a table
|
||||
assert.equal(readTable(makeDoc(), "nope"), null); // no such id
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insertTableRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("insertTableRow appends a 4th row, copies header colwidths, fresh unique ids", () => {
|
||||
const input = makeDoc();
|
||||
const snap = snapshot(input);
|
||||
const existingIds = allIds(input);
|
||||
|
||||
const { doc: out, inserted } = insertTableRow(input, "#1", ["x", "y", "z"]);
|
||||
assert.equal(inserted, true);
|
||||
|
||||
// Input not mutated.
|
||||
assert.deepEqual(input, snap);
|
||||
|
||||
const tbl = out.content[1];
|
||||
assert.equal(tbl.content.length, 4);
|
||||
const newRow = tbl.content[3];
|
||||
assert.equal(newRow.type, "tableRow");
|
||||
assert.equal(newRow.content.length, 3);
|
||||
|
||||
// Cell texts.
|
||||
assert.deepEqual(
|
||||
newRow.content.map((c) => c.content[0].content[0]?.text),
|
||||
["x", "y", "z"],
|
||||
);
|
||||
// Colwidths copied from the header row.
|
||||
assert.deepEqual(
|
||||
newRow.content.map((c) => c.attrs.colwidth),
|
||||
[[120], [200], [150]],
|
||||
);
|
||||
// colspan/rowspan present.
|
||||
for (const c of newRow.content) {
|
||||
assert.equal(c.attrs.colspan, 1);
|
||||
assert.equal(c.attrs.rowspan, 1);
|
||||
}
|
||||
|
||||
// New paragraph ids are unique and not equal to any existing id.
|
||||
const newIds = newRow.content.map((c) => c.content[0].attrs.id);
|
||||
assert.equal(new Set(newIds).size, 3);
|
||||
for (const id of newIds) {
|
||||
assert.ok(typeof id === "string" && id.length > 0);
|
||||
assert.equal(existingIds.has(id), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("insertTableRow at index 0 inserts before the header and pads to 3 cells", () => {
|
||||
const { doc: out, inserted } = insertTableRow(makeDoc(), "#1", ["x"], 0);
|
||||
assert.equal(inserted, true);
|
||||
|
||||
const tbl = out.content[1];
|
||||
assert.equal(tbl.content.length, 4);
|
||||
const newRow = tbl.content[0]; // inserted at the front
|
||||
assert.equal(newRow.content.length, 3);
|
||||
// First cell "x", remaining two empty.
|
||||
assert.deepEqual(
|
||||
newRow.content.map((c) => c.content[0].content.length),
|
||||
[1, 0, 0],
|
||||
);
|
||||
assert.equal(newRow.content[0].content[0].content[0].text, "x");
|
||||
});
|
||||
|
||||
test("insertTableRow throws when given more cells than columns", () => {
|
||||
assert.throws(
|
||||
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
|
||||
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("insertTableRow on a missing table returns inserted:false", () => {
|
||||
const { inserted } = insertTableRow(makeDoc(), "#0", ["x"]);
|
||||
assert.equal(inserted, false);
|
||||
});
|
||||
|
||||
// A header cell uses type "tableHeader" (vs. "tableCell" for data cells).
|
||||
const headerCell = (paraId, text, colwidth) => ({
|
||||
type: "tableHeader",
|
||||
attrs: { colspan: 1, rowspan: 1, ...(colwidth ? { colwidth } : {}) },
|
||||
content: [para(paraId, text)],
|
||||
});
|
||||
|
||||
// Table whose first row uses tableHeader cells.
|
||||
const makeHeaderDoc = () =>
|
||||
doc({
|
||||
type: "table",
|
||||
content: [
|
||||
row(headerCell("hA", "A"), headerCell("hB", "B")),
|
||||
row(cell("p10", "r1c0"), cell("p11", "r1c1")),
|
||||
],
|
||||
});
|
||||
|
||||
test("insertTableRow at index 0 inherits the header cell type (tableHeader)", () => {
|
||||
const { doc: out, inserted } = insertTableRow(makeHeaderDoc(), "#0", ["x", "y"], 0);
|
||||
assert.equal(inserted, true);
|
||||
|
||||
const tbl = out.content[0];
|
||||
const newRow = tbl.content[0]; // landed at index 0
|
||||
// The new row's cells inherit the header type.
|
||||
assert.deepEqual(
|
||||
newRow.content.map((c) => c.type),
|
||||
["tableHeader", "tableHeader"],
|
||||
);
|
||||
assert.equal(newRow.content[0].content[0].content[0].text, "x");
|
||||
});
|
||||
|
||||
test("insertTableRow append produces data cells (tableCell), not header cells", () => {
|
||||
const { doc: out, inserted } = insertTableRow(makeHeaderDoc(), "#0", ["x", "y"]);
|
||||
assert.equal(inserted, true);
|
||||
|
||||
const tbl = out.content[0];
|
||||
const newRow = tbl.content[tbl.content.length - 1]; // appended last
|
||||
assert.deepEqual(
|
||||
newRow.content.map((c) => c.type),
|
||||
["tableCell", "tableCell"],
|
||||
);
|
||||
});
|
||||
|
||||
// Ragged table: row 0 has 2 cols, a later row has 3.
|
||||
const makeRaggedDoc = () =>
|
||||
doc({
|
||||
type: "table",
|
||||
content: [
|
||||
row(cell("a0", "a0"), cell("a1", "a1")),
|
||||
row(cell("b0", "b0"), cell("b1", "b1"), cell("b2", "b2")),
|
||||
],
|
||||
});
|
||||
|
||||
test("insertTableRow uses the max column count across all rows (ragged table)", () => {
|
||||
// colCount is 3 (the widest row), so 3 cells are accepted...
|
||||
const { doc: out, inserted } = insertTableRow(makeRaggedDoc(), "#0", ["x", "y", "z"]);
|
||||
assert.equal(inserted, true);
|
||||
const tbl = out.content[0];
|
||||
const newRow = tbl.content[tbl.content.length - 1];
|
||||
assert.equal(newRow.content.length, 3);
|
||||
assert.deepEqual(
|
||||
newRow.content.map((c) => c.content[0].content[0]?.text),
|
||||
["x", "y", "z"],
|
||||
);
|
||||
|
||||
// ...but 4 cells exceed the widest row and throw.
|
||||
assert.throws(
|
||||
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
|
||||
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("insertTableRow into an empty table uses colCount = supplied cells", () => {
|
||||
const empty = doc({ type: "table", content: [] });
|
||||
const { doc: out, inserted } = insertTableRow(empty, "#0", ["x", "y", "z"]);
|
||||
assert.equal(inserted, true);
|
||||
const tbl = out.content[0];
|
||||
assert.equal(tbl.content.length, 1);
|
||||
assert.equal(tbl.content[0].content.length, 3);
|
||||
assert.deepEqual(
|
||||
tbl.content[0].content.map((c) => c.content[0].content[0]?.text),
|
||||
["x", "y", "z"],
|
||||
);
|
||||
});
|
||||
|
||||
test("insertTableRow mints 12-char [a-z0-9] ids that are unique and non-colliding", () => {
|
||||
const input = makeDoc();
|
||||
const existingIds = allIds(input);
|
||||
const { doc: out } = insertTableRow(input, "#1", ["x", "y", "z"]);
|
||||
|
||||
const tbl = out.content[1];
|
||||
const newRow = tbl.content[tbl.content.length - 1];
|
||||
const newIds = newRow.content.map((c) => c.content[0].attrs.id);
|
||||
|
||||
// Docmost-style: exactly 12 chars from lowercase a-z0-9.
|
||||
for (const id of newIds) {
|
||||
assert.match(id, /^[a-z0-9]{12}$/);
|
||||
assert.equal(existingIds.has(id), false); // no collision with the doc
|
||||
}
|
||||
// All distinct within the new row.
|
||||
assert.equal(new Set(newIds).size, newIds.length);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteTableRow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("deleteTableRow removes the 3rd row -> rows:2", () => {
|
||||
const { doc: out, deleted } = deleteTableRow(makeDoc(), "#1", 2);
|
||||
assert.equal(deleted, true);
|
||||
const tbl = out.content[1];
|
||||
assert.equal(tbl.content.length, 2);
|
||||
// The removed row was the second data row (r2*).
|
||||
assert.deepEqual(
|
||||
tbl.content.map((r) => r.content[0].content[0].content[0]?.text ?? ""),
|
||||
["A", "r1c0"],
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteTableRow out-of-range index throws", () => {
|
||||
assert.throws(
|
||||
() => deleteTableRow(makeDoc(), "#1", 9),
|
||||
/table_delete_row: row index 9 out of range \(table has 3 row\(s\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteTableRow refuses to delete the only row", () => {
|
||||
const single = doc({
|
||||
type: "table",
|
||||
content: [row(cell("only", "x"))],
|
||||
});
|
||||
assert.throws(
|
||||
() => deleteTableRow(single, "#0", 0),
|
||||
/refusing to delete the only row of the table/,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateTableCell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", () => {
|
||||
const input = makeDoc();
|
||||
const snap = snapshot(input);
|
||||
const { doc: out, updated } = updateTableCell(input, "#1", 1, 1, "Z");
|
||||
assert.equal(updated, true);
|
||||
|
||||
// Input not mutated.
|
||||
assert.deepEqual(input, snap);
|
||||
|
||||
const targetCell = out.content[1].content[1].content[1];
|
||||
assert.equal(targetCell.content.length, 1);
|
||||
const p = targetCell.content[0];
|
||||
assert.equal(p.type, "paragraph");
|
||||
assert.equal(p.attrs.id, "p11"); // preserved
|
||||
assert.equal(p.content[0].text, "Z");
|
||||
|
||||
// Cell attrs preserved.
|
||||
assert.equal(targetCell.attrs.colspan, 1);
|
||||
assert.equal(targetCell.attrs.rowspan, 1);
|
||||
});
|
||||
|
||||
test("updateTableCell out-of-range row/col throws", () => {
|
||||
assert.throws(
|
||||
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
|
||||
/table_update_cell: cell \[9,0\] out of range/,
|
||||
);
|
||||
assert.throws(
|
||||
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
|
||||
/table_update_cell: cell \[0,9\] out of range/,
|
||||
);
|
||||
});
|
||||
303
packages/mcp/test/unit/transforms.test.mjs
Normal file
303
packages/mcp/test/unit/transforms.test.mjs
Normal file
@@ -0,0 +1,303 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
getList,
|
||||
insertMarkerAfter,
|
||||
setCalloutRange,
|
||||
noteItem,
|
||||
mdToInlineNodes,
|
||||
commentsToFootnotes,
|
||||
} from "../../build/lib/transforms.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
const t = (text, marks) => (marks ? { type: "text", text, marks } : { type: "text", text });
|
||||
const para = (id, ...children) => ({
|
||||
type: "paragraph",
|
||||
attrs: { id },
|
||||
content: children,
|
||||
});
|
||||
const heading = (id, text) => ({
|
||||
type: "heading",
|
||||
attrs: { id, level: 2 },
|
||||
content: [t(text)],
|
||||
});
|
||||
const olist = (...items) => ({ type: "orderedList", content: items });
|
||||
const li = (text) => ({
|
||||
type: "listItem",
|
||||
content: [{ type: "paragraph", content: [t(text)] }],
|
||||
});
|
||||
const doc = (...children) => ({ type: "doc", content: children });
|
||||
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// blockText / walk / getList
|
||||
// ---------------------------------------------------------------------------
|
||||
test("blockText concatenates nested inline text", () => {
|
||||
assert.equal(blockText(para("p", t("a"), t("b"), t("c"))), "abc");
|
||||
});
|
||||
|
||||
test("walk visits every node depth-first", () => {
|
||||
const d = doc(para("p1", t("x")), olist(li("y")));
|
||||
const types = [];
|
||||
walk(d, (n) => types.push(n.type));
|
||||
assert.deepEqual(types, [
|
||||
"doc",
|
||||
"paragraph",
|
||||
"text",
|
||||
"orderedList",
|
||||
"listItem",
|
||||
"paragraph",
|
||||
"text",
|
||||
]);
|
||||
});
|
||||
|
||||
test("getList finds an orderedList without an id", () => {
|
||||
const d = doc(para("p", t("x")), olist(li("one")));
|
||||
const found = getList(d, (n) => n.type === "orderedList");
|
||||
assert.ok(found);
|
||||
assert.equal(found.type, "orderedList");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insertMarkerAfter — mark-safe split
|
||||
// ---------------------------------------------------------------------------
|
||||
test("insertMarkerAfter splits a marked run and inserts an UNMARKED marker", () => {
|
||||
// A paragraph: "see " (plain) + "the link" (link mark) + " here" (plain).
|
||||
const link = [{ type: "link", attrs: { href: "http://x" } }];
|
||||
const original = doc(
|
||||
para("p1", t("see "), t("the link", link), t(" here")),
|
||||
);
|
||||
const before = snapshot(original);
|
||||
|
||||
const { doc: out, inserted } = insertMarkerAfter(
|
||||
original,
|
||||
"the link",
|
||||
"[1]",
|
||||
);
|
||||
assert.equal(inserted, true);
|
||||
// The caller's object is untouched (deep clone).
|
||||
assert.deepEqual(original, before);
|
||||
|
||||
const inline = out.content[0].content;
|
||||
// Expect: "see "(plain), "the link"(link), " [1]"(NO marks), " here"(plain).
|
||||
const marker = inline.find((n) => n.text === " [1]");
|
||||
assert.ok(marker, "marker run present");
|
||||
assert.equal(marker.marks, undefined, "marker carries no marks");
|
||||
|
||||
// The link run kept its mark verbatim.
|
||||
const linkRun = inline.find((n) => n.text === "the link");
|
||||
assert.deepEqual(linkRun.marks, link);
|
||||
|
||||
// Plain text reads correctly with the marker placed right after the anchor.
|
||||
assert.equal(blockText(out.content[0]), "see the link [1] here");
|
||||
});
|
||||
|
||||
test("insertMarkerAfter respects beforeBlock and reports not-found", () => {
|
||||
const d = doc(para("p1", t("alpha")), para("p2", t("beta")));
|
||||
// anchor only in block index 1, but search limited to blocks < 1
|
||||
const r = insertMarkerAfter(d, "beta", "[1]", { beforeBlock: 1 });
|
||||
assert.equal(r.inserted, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCalloutRange
|
||||
// ---------------------------------------------------------------------------
|
||||
test("setCalloutRange rewrites [1]…[K] to [1]…[n]", () => {
|
||||
const d = doc({
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [para("c", t("Footnotes [1]…[3] are translator notes."))],
|
||||
});
|
||||
const { doc: out, changed } = setCalloutRange(d, 7);
|
||||
assert.equal(changed, 1);
|
||||
assert.equal(blockText(out), "Footnotes [1]…[7] are translator notes.");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// noteItem / mdToInlineNodes
|
||||
// ---------------------------------------------------------------------------
|
||||
test("noteItem wraps inline nodes in a listItem with a fresh paragraph id", () => {
|
||||
const item = noteItem([t("hello")]);
|
||||
assert.equal(item.type, "listItem");
|
||||
assert.equal(item.content[0].type, "paragraph");
|
||||
assert.ok(item.content[0].attrs.id, "has a fresh id");
|
||||
assert.deepEqual(item.content[0].content, [t("hello")]);
|
||||
});
|
||||
|
||||
test("mdToInlineNodes splits a bold lead and strips a prefix", () => {
|
||||
const nodes = mdToInlineNodes("комментарий: **Lead.** body text");
|
||||
// bold lead node + plain remainder
|
||||
assert.equal(nodes[0].text, "Lead.");
|
||||
assert.deepEqual(nodes[0].marks, [{ type: "bold" }]);
|
||||
assert.ok(nodes[1].text.includes("body text"));
|
||||
assert.equal(nodes[1].marks, undefined);
|
||||
});
|
||||
|
||||
test("mdToInlineNodes strips a 'N. ' numeric prefix", () => {
|
||||
const nodes = mdToInlineNodes("3. plain note");
|
||||
assert.equal(nodes.map((n) => n.text).join(""), "plain note");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// commentsToFootnotes — renumber by reading position on a small fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
test("commentsToFootnotes anchors comments and renumbers by position", () => {
|
||||
// Body has an EXISTING footnote [1] in the second paragraph; we add two
|
||||
// inline comments anchored to text in the first and third paragraphs. After
|
||||
// running, markers must be renumbered 1,2,3 in reading order and the notes
|
||||
// list reordered to match.
|
||||
const callout = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [para("c", t("Notes [1]…[1] follow."))],
|
||||
};
|
||||
const d = doc(
|
||||
callout,
|
||||
para("p1", t("First mentions apple.")),
|
||||
para("p2", t("Second already has a note [1] here.")),
|
||||
para("p3", t("Third mentions banana.")),
|
||||
heading("h", "Примечания переводчика"),
|
||||
olist(li("existing note one")), // matches the existing [1]
|
||||
);
|
||||
|
||||
const comments = [
|
||||
{ id: "cA", content: "apple note", selection: "apple" },
|
||||
{ id: "cB", content: "banana note", selection: "banana" },
|
||||
];
|
||||
|
||||
const { doc: out, consumed } = commentsToFootnotes(d, comments);
|
||||
assert.deepEqual(consumed.sort(), ["cA", "cB"]);
|
||||
|
||||
// Markers in reading order: p1 "apple"->[1], p2 existing->[2], p3 "banana"->[3]
|
||||
assert.match(blockText(out.content[1]), /\[1\]/);
|
||||
assert.match(blockText(out.content[2]), /\[2\]/);
|
||||
assert.match(blockText(out.content[3]), /\[3\]/);
|
||||
|
||||
// No stray placeholders remain.
|
||||
const allText = blockText(out);
|
||||
assert.doesNotMatch(allText, / F\d+ /);
|
||||
|
||||
// Notes list reordered to [apple, existing, banana] (reading order).
|
||||
const list = out.content.find((n) => n.type === "orderedList");
|
||||
assert.equal(list.content.length, 3);
|
||||
assert.equal(blockText(list.content[0]), "apple note");
|
||||
assert.equal(blockText(list.content[1]), "existing note one");
|
||||
assert.equal(blockText(list.content[2]), "banana note");
|
||||
|
||||
// Callout range synced to 3 notes.
|
||||
assert.match(blockText(out.content[0]), /\[1\]…\[3\]/);
|
||||
});
|
||||
|
||||
test("commentsToFootnotes throws when the notes heading is missing", () => {
|
||||
const d = doc(para("p", t("no notes section")));
|
||||
assert.throws(
|
||||
() => commentsToFootnotes(d, [{ id: "x", content: "y", selection: "no" }]),
|
||||
/heading .* not found/,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 1: the placeholder sentinel must not collide with real "F<digits>" /
|
||||
// "FN<digits>" text. Body text "F1"/"FN2"/"F12" near a real comment anchor must
|
||||
// be left untouched; only the real comment becomes a footnote. "FN2" is the key
|
||||
// case: the old printable " FN<i> " sentinel could collide with prose like "FN2",
|
||||
// which the NUL-delimited "\u0000FN<i>\u0000" sentinel makes impossible.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched", () => {
|
||||
const d = doc(
|
||||
para("p1", t("Press F1 for help, model FN2 and F12 for tools near apple here.")),
|
||||
heading("h", "Примечания переводчика"),
|
||||
olist(), // empty notes list; the single comment supplies the only note
|
||||
);
|
||||
|
||||
const comments = [{ id: "cA", content: "apple note", selection: "apple" }];
|
||||
|
||||
const { doc: out, consumed } = commentsToFootnotes(d, comments);
|
||||
assert.deepEqual(consumed, ["cA"]);
|
||||
|
||||
const bodyText = blockText(out.content[0]);
|
||||
// The literal "F1"/"FN2"/"F12" prose is preserved verbatim (no bogus
|
||||
// footnotes, no eaten spaces around them).
|
||||
assert.match(bodyText, /Press F1 for help, model FN2 and F12 for tools/);
|
||||
// Exactly one real footnote marker was produced, at the anchored word.
|
||||
const markerCount = (bodyText.match(/\[\d+\]/g) || []).length;
|
||||
assert.equal(markerCount, 1);
|
||||
assert.match(bodyText, /apple \[1\]/);
|
||||
|
||||
// Exactly one note in the list — "F1"/"FN2"/"F12" did not spawn extra notes.
|
||||
const list = out.content.find((n) => n.type === "orderedList");
|
||||
assert.equal(list.content.length, 1);
|
||||
assert.equal(blockText(list.content[0]), "apple note");
|
||||
|
||||
// No stray placeholder sentinel remains anywhere: the NUL-delimited sentinel
|
||||
// is fully consumed by the renumber pass, so no raw NUL control char persists
|
||||
// in the returned doc. We deliberately do NOT assert absence of the printable
|
||||
// " FN<i> " shape: the body intentionally contains real prose "model FN2 and",
|
||||
// which must survive verbatim (see the match assertion above) - that is exactly
|
||||
// why the old printable sentinel was unsafe and the NUL sentinel is not.
|
||||
assert.doesNotMatch(blockText(out), /\u0000/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 2: an out-of-range body marker must throw, not silently drop the note.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("commentsToFootnotes throws on an out-of-range body marker", () => {
|
||||
// Body marker [9] but the notes list has only 1 item -> inconsistent doc.
|
||||
const d = doc(
|
||||
para("p1", t("Some text with a dangling marker [9] here.")),
|
||||
heading("h", "Примечания переводчика"),
|
||||
olist(li("the only note")),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => commentsToFootnotes(d, []),
|
||||
/footnote \[9\] has no matching note \(notes list has 1 items\); document is inconsistent/,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bug 4: a non-disclaimer callout in the body gets its [N] markers renumbered;
|
||||
// a disclaimer callout carrying a "[1]…[K]" range is left out of renumbering.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("commentsToFootnotes renumbers body callouts but skips the disclaimer range", () => {
|
||||
const disclaimer = {
|
||||
type: "callout",
|
||||
attrs: { type: "info" },
|
||||
content: [para("d", t("Notes [1]…[2] follow."))],
|
||||
};
|
||||
const bodyCallout = {
|
||||
type: "callout",
|
||||
attrs: { type: "warning" },
|
||||
content: [para("bc", t("Important point already noted [1] above."))],
|
||||
};
|
||||
const d = doc(
|
||||
disclaimer,
|
||||
bodyCallout,
|
||||
para("p2", t("Then a second mention with [2] too.")),
|
||||
heading("h", "Примечания переводчика"),
|
||||
olist(li("first note"), li("second note")),
|
||||
);
|
||||
|
||||
const { doc: out, consumed } = commentsToFootnotes(d, []);
|
||||
assert.deepEqual(consumed, []);
|
||||
|
||||
// The disclaimer's "[1]…[K]" range is NOT treated as body markers: it stays
|
||||
// a range and is synced to the note count (2), not renumbered into [1],[2].
|
||||
assert.match(blockText(out.content[0]), /\[1\]…\[2\]/);
|
||||
|
||||
// The body callout's [1] is renumbered as a real reading-order marker.
|
||||
assert.match(blockText(out.content[1]), /noted \[1\] above/);
|
||||
// The following paragraph's [2] keeps reading order.
|
||||
assert.match(blockText(out.content[2]), /with \[2\] too/);
|
||||
|
||||
// Notes list still has the two original notes in order.
|
||||
const list = out.content.find((n) => n.type === "orderedList");
|
||||
assert.equal(list.content.length, 2);
|
||||
assert.equal(blockText(list.content[0]), "first note");
|
||||
assert.equal(blockText(list.content[1]), "second note");
|
||||
});
|
||||
Reference in New Issue
Block a user