Files
gitmost/packages/mcp/test/unit/transforms.test.mjs
vvzvlad 1f5987d6b0 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).
2026-06-16 23:54:53 +03:00

304 lines
12 KiB
JavaScript

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");
});