list_pages gains an opt-in `tree` parameter on both surfaces (the
@docmost/mcp server tool and the AI-chat agent tool), which share the
same DocmostClient.listPages. Default behavior (recent-by-updatedAt flat
list) is unchanged.
- client.ts: listPages(spaceId?, limit=50, tree=false); when tree is
true it requires spaceId (throws a specific error otherwise), walks the
sidebar tree via the existing bounded/cycle-safe enumerateSpacePages,
and returns a nested tree; limit is ignored in tree mode.
- lib/tree.ts: new pure buildPageTree() — lean nodes { id, slugId, title,
children? }, children sorted by position (code-unit order), orphans
promoted to roots, cycle-safe.
- index.ts + ai-chat-tools.service.ts: expose `tree` in the tool schemas
and descriptions; docmost-client.loader.ts: mirror the new signature.
- tests: add packages/mcp/test/unit/tree.test.mjs (nesting, ordering,
lean shape, orphan promotion, cycle/self-reference safety).
- rebuild @docmost/mcp (build/ is tracked and loaded at runtime).
140 lines
3.8 KiB
JavaScript
140 lines
3.8 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { buildPageTree } from "../../build/lib/tree.js";
|
|
|
|
test("buildPageTree nests two children under their parent", () => {
|
|
const tree = buildPageTree([
|
|
{ id: "root", slugId: "s-root", title: "Root", position: "a0" },
|
|
{
|
|
id: "c1",
|
|
slugId: "s-c1",
|
|
title: "Child 1",
|
|
position: "a0",
|
|
parentPageId: "root",
|
|
},
|
|
{
|
|
id: "c2",
|
|
slugId: "s-c2",
|
|
title: "Child 2",
|
|
position: "a1",
|
|
parentPageId: "root",
|
|
},
|
|
]);
|
|
|
|
assert.equal(tree.length, 1);
|
|
assert.equal(tree[0].id, "root");
|
|
assert.equal(tree[0].children.length, 2);
|
|
assert.deepEqual(
|
|
tree[0].children.map((c) => c.id),
|
|
["c1", "c2"],
|
|
);
|
|
});
|
|
|
|
test("buildPageTree sorts children and roots ascending by position", () => {
|
|
const tree = buildPageTree([
|
|
// Roots provided out of order.
|
|
{ id: "r2", slugId: "s-r2", title: "R2", position: "a2" },
|
|
{ id: "r1", slugId: "s-r1", title: "R1", position: "a1" },
|
|
// Children provided out of order.
|
|
{
|
|
id: "c2",
|
|
slugId: "s-c2",
|
|
title: "C2",
|
|
position: "b1",
|
|
parentPageId: "r1",
|
|
},
|
|
{
|
|
id: "c1",
|
|
slugId: "s-c1",
|
|
title: "C1",
|
|
position: "b0",
|
|
parentPageId: "r1",
|
|
},
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
tree.map((n) => n.id),
|
|
["r1", "r2"],
|
|
);
|
|
assert.deepEqual(
|
|
tree[0].children.map((c) => c.id),
|
|
["c1", "c2"],
|
|
);
|
|
});
|
|
|
|
test("buildPageTree omits the children key for leaf nodes", () => {
|
|
const tree = buildPageTree([
|
|
{ id: "leaf", slugId: "s-leaf", title: "Leaf", position: "a0" },
|
|
]);
|
|
|
|
assert.equal(tree.length, 1);
|
|
assert.equal("children" in tree[0], false);
|
|
});
|
|
|
|
test("buildPageTree promotes an orphan (missing parent) to a root", () => {
|
|
const tree = buildPageTree([
|
|
{
|
|
id: "orphan",
|
|
slugId: "s-orphan",
|
|
title: "Orphan",
|
|
position: "a0",
|
|
// parentPageId references an id NOT present in the input.
|
|
parentPageId: "does-not-exist",
|
|
},
|
|
]);
|
|
|
|
assert.equal(tree.length, 1);
|
|
assert.equal(tree[0].id, "orphan");
|
|
assert.equal("children" in tree[0], false);
|
|
});
|
|
|
|
test("buildPageTree is cycle-safe (two-node cycle does not recurse or appear in output)", () => {
|
|
// A <-> B cycle: each node's parent is present, so neither becomes a root.
|
|
// The cycle component is unreachable from the returned roots, so the output
|
|
// is finite and JSON-serializable (no infinite recursion / circular JSON).
|
|
const tree = buildPageTree([
|
|
{ id: "A", slugId: "s-A", title: "A", position: "a0", parentPageId: "B" },
|
|
{ id: "B", slugId: "s-B", title: "B", position: "a1", parentPageId: "A" },
|
|
]);
|
|
|
|
assert.deepEqual(tree, []);
|
|
// Must not throw on a structure that contains the cyclic component internally.
|
|
assert.doesNotThrow(() => JSON.stringify(tree));
|
|
});
|
|
|
|
test("buildPageTree is self-reference-safe (node parented to itself is dropped, no crash)", () => {
|
|
const tree = buildPageTree([
|
|
{ id: "root", slugId: "s-root", title: "Root", position: "a0" },
|
|
// Self-referencing node: its parent is present (itself) -> not a root.
|
|
{ id: "self", slugId: "s-self", title: "Self", position: "a0", parentPageId: "self" },
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
tree.map((n) => n.id),
|
|
["root"],
|
|
);
|
|
assert.doesNotThrow(() => JSON.stringify(tree));
|
|
});
|
|
|
|
test("buildPageTree output shape is lean (drops position/parentPageId/hasChildren)", () => {
|
|
const tree = buildPageTree([
|
|
{
|
|
id: "p1",
|
|
slugId: "s-p1",
|
|
title: "P1",
|
|
position: "a0",
|
|
parentPageId: null,
|
|
hasChildren: false,
|
|
spaceId: "space-1",
|
|
},
|
|
]);
|
|
|
|
const node = tree[0];
|
|
assert.deepEqual(node, { id: "p1", slugId: "s-p1", title: "P1" });
|
|
assert.equal("position" in node, false);
|
|
assert.equal("parentPageId" in node, false);
|
|
assert.equal("hasChildren" in node, false);
|
|
assert.equal("spaceId" in node, false);
|
|
});
|