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

154 lines
4.3 KiB
JavaScript

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