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

339 lines
11 KiB
JavaScript

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