Files
gitmost/packages/mcp/test/unit/node-ops-table.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

302 lines
9.2 KiB
JavaScript

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