Merge pull request 'feat(mcp): getTree — иерархия пространства/поддерева одним вызовом (#443, часть 2/3)' (#469) from feat/443-get-tree into feat/443-search

Reviewed-on: #469
This commit was merged in pull request #469.
This commit is contained in:
2026-07-10 23:45:29 +03:00
8 changed files with 380 additions and 16 deletions
@@ -59,6 +59,7 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.getWorkspace();
void client.getSpaces();
void client.listPages(s, n, true);
void client.getTree(s, s, n);
void client.listSidebarPages(s, s);
void client.getOutline(s);
void client.getPageJson(s);
@@ -23,6 +23,7 @@ type DocmostClientMethod =
| 'getWorkspace'
| 'getSpaces'
| 'listPages'
| 'getTree'
| 'listSidebarPages'
| 'getOutline'
| 'getPageJson'
+40 -3
View File
@@ -811,13 +811,17 @@ export class DocmostClient {
* large instances, so a single bounded page of results is returned (default
* 50, max 100) via the `/pages/recent` feed.
*
* Tree (`tree` true): the space's FULL page hierarchy as a nested tree (each
* node has a `children` array). This mode REQUIRES `spaceId` (a page tree is
* Tree (`tree` true): DEPRECATED — prefer `getTree`, which shares this exact
* code path (a single `/pages/tree` request via `enumerateSpacePages` +
* `buildPageTree`) but returns the compact `{pageId, title, children?,
* hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is
* kept for backward compatibility; it REQUIRES `spaceId` (a page tree is
* scoped to one space) and IGNORES `limit` — the whole hierarchy is returned.
* It fetches the tree via `enumerateSpacePages`, which on the fork server
* resolves to a single `/pages/tree` request returning the whole
* permission-filtered flat page set (soft-deleted pages excluded
* server-side).
* server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for
* stock upstream servers that lack `/pages/tree`.
*/
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
await this.ensureAuthenticated();
@@ -841,6 +845,39 @@ export class DocmostClient {
return items.map((page: any) => filterPage(page));
}
/**
* Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE
* request — the #443 `getTree` tool. Shares its whole code path with
* `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree`
* (with the cursor-BFS only as a fallback for stock upstream servers that lack
* the endpoint), then `buildPageTree` nests the flat, permission-filtered,
* position-ordered list. No second tree fetch, no per-node BFS.
*
* - `rootPageId` — restrict to that page's subtree; the server seeds the CTE
* with the page itself, so the result is exactly ONE root (the page and its
* descendants). Omit it for the whole space.
* - `maxDepth` — trim the response to that many levels (roots = depth 1) to
* save tokens; the server still returns everything in one request, the cut
* is applied in `buildPageTree` AFTER the full tree is built. A node whose
* children were cut carries `hasChildren: true` (source of truth = the flat
* item's server `hasChildren`) so the caller can descend with a follow-up
* `getTree(spaceId, rootPageId=that node)` call.
*
* Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID
* `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId`
* (a page tree is scoped to one space).
*/
async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) {
await this.ensureAuthenticated();
if (!spaceId) {
throw new Error(
"getTree: spaceId is required (a page tree is scoped to one space).",
);
}
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
return buildPageTree(pages, { shape: "getTree", maxDepth });
}
/**
* List sidebar pages for a space. With no pageId the request returns the
* space ROOT pages; with a pageId it returns the direct CHILDREN of that
+85 -8
View File
@@ -1,11 +1,30 @@
/**
* Options for `buildPageTree`. Fully OPTIONAL so the existing call form
* `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId,
* title, children?}` output, no depth cut) unchanged.
*
* - `shape: "getTree"` — emit the #443 `getTree` output node shape
* `{pageId, title, children?, hasChildren?}` instead of the lean
* `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are
* never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer).
* - `maxDepth` — trim the built tree to this many levels (root nodes are
* depth 1). Only meaningful together with `shape: "getTree"` (the lean shape
* has no `hasChildren` to signal a cut). See the depth logic below.
*/
export interface BuildPageTreeOptions {
shape?: "lean" | "getTree";
maxDepth?: number;
}
/**
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
* by `enumerateSpacePages`) into a nested tree.
*
* Input: a flat array of nodes. Each node is expected to carry at least
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
* { id, slugId, title, position, parentPageId } (extra fields are ignored),
* plus a server `hasChildren` boolean used by the `getTree` shape below.
*
* Output: an array of ROOT nodes, each shaped as
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
* { id, slugId, title, children? }
* where `children` is the array of child nodes (same shape, recursively). The
* `children` key is OMITTED entirely when a node has no children — consistent
@@ -13,6 +32,14 @@
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
* are intentionally dropped from the output).
*
* Output (`shape: "getTree"`, the #443 tool shape): each node is
* { pageId, title, children?, hasChildren? }
* — the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
* `hasChildren: true` is set ONLY on a node whose children exist on the server
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
*
* Linking rule: a node is attached as a child of `parentPageId` only when that
* parent id is actually present in the input. Otherwise — including a null /
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
@@ -26,18 +53,42 @@
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
* `position` sort last.
*
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
* <= N and drops the `children` of any node AT depth N. A node whose children
* were dropped this way gets `hasChildren: true` when it actually had children
* in the flat input (source of truth = the server `hasChildren` flag), so the
* caller knows it can descend further with a follow-up `rootPageId` call. An
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
*
* Pure: no I/O, no network, deterministic.
*/
export function buildPageTree(nodes: any[]): any[] {
type OutputNode = {
export function buildPageTree(
nodes: any[],
options: BuildPageTreeOptions = {},
): any[] {
const getTreeShape = options.shape === "getTree";
// A finite, positive cut only; anything else means "no cut".
const maxDepth =
typeof options.maxDepth === "number" &&
Number.isFinite(options.maxDepth) &&
options.maxDepth > 0
? Math.floor(options.maxDepth)
: undefined;
type InternalNode = {
id: string;
// Retained internally for shaping; never all emitted at once.
slugId: any;
title: any;
children?: OutputNode[];
hasServerChildren: boolean;
children?: InternalNode[];
};
// Map id -> output node. Build the lean output shape up front.
const byId = new Map<string, OutputNode>();
// Map id -> internal node. Build up front; the output shape is projected at
// the very end so the maxDepth cut can consult `hasServerChildren`.
const byId = new Map<string, InternalNode>();
// Preserve the original position string for sorting (kept off the output).
const positionById = new Map<string, string | undefined>();
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
id: node.id,
slugId: node.slugId,
title: node.title,
hasServerChildren: node.hasChildren === true,
});
positionById.set(node.id, node.position);
}
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
}
roots.sort(byPosition);
return roots.map((id) => byId.get(id)!);
const rootNodes = roots.map((id) => byId.get(id)!);
// Project the internal nodes into the requested OUTPUT shape, applying the
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
const project = (node: InternalNode, depth: number): any => {
if (getTreeShape) {
const out: any = { pageId: node.id, title: node.title };
const atCut = maxDepth !== undefined && depth >= maxDepth;
if (!atCut && node.children && node.children.length > 0) {
out.children = node.children.map((c) => project(c, depth + 1));
} else if (atCut && node.hasServerChildren) {
// Children exist on the server but were trimmed by maxDepth: signal it
// so the caller can descend with a follow-up rootPageId call.
out.hasChildren = true;
}
return out;
}
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
if (node.children && node.children.length > 0) {
out.children = node.children.map((c) => project(c, depth + 1));
}
return out;
};
return rootNodes.map((n) => project(n, 1));
}
+2 -1
View File
@@ -40,7 +40,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
*/
export const ROUTING_PROSE =
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
@@ -82,6 +82,7 @@ const TOOL_FAMILY: Record<string, Family> = {
// READ
search: "READ",
listPages: "READ",
getTree: "READ",
listSpaces: "READ",
getOutline: "READ",
getNode: "READ",
+51 -4
View File
@@ -63,6 +63,7 @@ export type DocmostClientLike = Pick<
| 'getSpaces'
| 'listShares'
| 'listPages'
| 'getTree'
| 'getPage'
| 'getPageJson'
| 'getOutline'
@@ -917,11 +918,13 @@ export const SHARED_TOOL_SPECS = {
description:
'List the most recent pages (ordered by updatedAt, descending), ' +
'optionally scoped to a single space. Returns a bounded list (default ' +
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
"(with spaceId) to instead get the space's full page hierarchy as a " +
'nested tree.',
'50, max 100) — use search for lookups in large spaces. tree:true (with ' +
"spaceId) returns the space's full page hierarchy as a nested tree, but " +
'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
'maxDepth).',
tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.",
catalogLine:
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
buildShape: (z) => ({
spaceId: z
.string()
@@ -954,6 +957,50 @@ export const SHARED_TOOL_SPECS = {
),
},
getTree: {
mcpName: 'getTree',
inAppKey: 'getTree',
description:
"Get a space's page hierarchy (or one subtree) as a nested tree in a " +
'SINGLE request — completely and without loss. Each node is ' +
'`{ pageId, title, children? }`; children are ordered as in the sidebar. ' +
'Pass rootPageId to return only that page and its descendants (exactly ' +
'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' +
'depth 1, so maxDepth:1 returns only the roots); a node whose children ' +
'were trimmed carries `hasChildren:true` so you can descend later with ' +
'getTree(rootPageId=that page). Prefer this over listPages tree:true.',
tier: 'core',
catalogLine:
"getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.",
buildShape: (z) => ({
spaceId: z
.string()
.min(1)
.describe('The id of the space whose page tree to return.'),
rootPageId: z
.string()
.optional()
.describe(
'Optional page id: return only this page and its descendants (one root).',
),
maxDepth: z
.number()
.int()
.min(1)
.optional()
.describe(
'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' +
'roots; trimmed nodes carry hasChildren:true.',
),
}),
execute: (client, { spaceId, rootPageId, maxDepth }) =>
client.getTree(
spaceId as string,
rootPageId as string | undefined,
maxDepth as number | undefined,
),
},
createPage: {
mcpName: 'createPage',
inAppKey: 'createPage',
@@ -165,6 +165,45 @@ test("insertNode spec exists, describes markdown+node XOR, builds the full ancho
);
});
// #443: getTree — a space's page hierarchy (or a subtree) in one request.
test("getTree spec exists on both hosts, builds { spaceId, rootPageId?, maxDepth? }", () => {
const spec = SHARED_TOOL_SPECS.getTree;
assert.ok(spec, "getTree spec missing");
assert.equal(spec.mcpName, "getTree");
assert.equal(spec.inAppKey, "getTree");
// Shared spec: registered on BOTH hosts.
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["maxDepth", "rootPageId", "spaceId"]);
const schema = z.object(shape);
// spaceId required; rootPageId + maxDepth optional.
assert.doesNotThrow(() => schema.parse({ spaceId: "sp1" }));
assert.throws(() => schema.parse({}));
assert.doesNotThrow(() =>
schema.parse({ spaceId: "sp1", rootPageId: "p1", maxDepth: 2 }),
);
// maxDepth is an integer >= 1.
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 0 }));
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 1.5 }));
// The description advertises the output node shape, rootPageId, maxDepth, and
// steers away from the deprecated listPages tree:true.
assert.match(spec.description, /pageId/);
assert.match(spec.description, /rootPageId/);
assert.match(spec.description, /maxDepth/);
assert.match(spec.description, /hasChildren/);
assert.match(spec.description, /listPages tree:true/);
});
// #443: listPages tree:true is deprecated in favour of getTree.
test("listPages description deprecates tree:true and points at getTree", () => {
const spec = SHARED_TOOL_SPECS.listPages;
assert.match(spec.description, /DEPRECATED/i);
assert.match(spec.description, /getTree/);
});
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
+161
View File
@@ -137,3 +137,164 @@ test("buildPageTree output shape is lean (drops position/parentPageId/hasChildre
assert.equal("hasChildren" in node, false);
assert.equal("spaceId" in node, false);
});
// ---------------------------------------------------------------------------
// #443 getTree output shape: { pageId, title, children?, hasChildren? }
// ---------------------------------------------------------------------------
// A small representative space used across the getTree tests:
// r1 (Infrastructure)
// c1 (Datacenter A)
// g1 (Servers) [leaf]
// c2 (Datacenter B) [leaf]
// r2 (Notes) [leaf]
const SAMPLE = [
{ id: "r2", slugId: "s-r2", title: "Notes", position: "a1", icon: "📝", hasChildren: false },
{ id: "r1", slugId: "s-r1", title: "Infrastructure", position: "a0", icon: "🏢", hasChildren: true },
{ id: "c2", slugId: "s-c2", title: "Datacenter B", position: "b1", parentPageId: "r1", icon: "🅱️", hasChildren: false },
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", parentPageId: "r1", icon: "🅰️", hasChildren: true },
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", icon: "🖥️", hasChildren: false },
];
test("getTree shape: correct nesting + order-by-position, only {pageId,title,children?}, no leak", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree" });
// Roots sorted by position: r1 (a0) before r2 (a1).
assert.deepEqual(
tree.map((n) => n.pageId),
["r1", "r2"],
);
// r1's children sorted by position: c1 (b0) before c2 (b1).
assert.deepEqual(
tree[0].children.map((n) => n.pageId),
["c1", "c2"],
);
// Deep nesting: g1 under c1.
assert.deepEqual(
tree[0].children[0].children.map((n) => n.pageId),
["g1"],
);
// No slugId/icon/position/parentPageId/hasChildren leak on any node.
const walk = (nodes) => {
for (const n of nodes) {
assert.deepEqual(
Object.keys(n).sort(),
n.children ? ["children", "pageId", "title"] : ["pageId", "title"],
`unexpected keys on ${n.pageId}: ${Object.keys(n)}`,
);
assert.equal("slugId" in n, false);
assert.equal("icon" in n, false);
assert.equal("position" in n, false);
assert.equal("parentPageId" in n, false);
// Fully-expanded tree (no maxDepth): hasChildren never set.
assert.equal("hasChildren" in n, false);
if (n.children) walk(n.children);
}
};
walk(tree);
});
test("getTree maxDepth:1 returns roots only, each with hasChildren from the flat item", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 1 });
assert.deepEqual(
tree.map((n) => n.pageId),
["r1", "r2"],
);
// No children arrays at depth 1 when maxDepth:1.
for (const n of tree) assert.equal("children" in n, false);
// r1 has children on the server -> hasChildren:true; r2 is a leaf -> omitted.
assert.equal(tree[0].hasChildren, true);
assert.equal("hasChildren" in tree[1], false);
});
test("getTree maxDepth:2 cuts grandchildren; hasChildren only on the cut interior node", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 2 });
const r1 = tree[0];
// Depth-1 node r1 was EXPANDED (its children are present) -> no hasChildren.
assert.equal("hasChildren" in r1, false);
assert.equal(r1.children.length, 2);
const [c1, c2] = r1.children;
// c1 is at depth 2 (the cut) and has children on the server -> hasChildren:true,
// and its grandchild g1 is NOT present.
assert.equal(c1.pageId, "c1");
assert.equal("children" in c1, false);
assert.equal(c1.hasChildren, true);
// c2 is at depth 2 but is a leaf on the server -> hasChildren omitted.
assert.equal(c2.pageId, "c2");
assert.equal("children" in c2, false);
assert.equal("hasChildren" in c2, false);
// r2 is a depth-1 leaf -> no hasChildren, no children.
assert.equal("hasChildren" in tree[1], false);
});
test("getTree hasChildren is set ONLY on depth-cut nodes (not leaves, not expanded interior nodes)", () => {
// Full tree (no cut): NO node anywhere carries hasChildren.
const full = buildPageTree(SAMPLE, { shape: "getTree" });
const anyHasChildren = (nodes) =>
nodes.some((n) => "hasChildren" in n || (n.children && anyHasChildren(n.children)));
assert.equal(anyHasChildren(full), false);
});
test("getTree orphan (parent filtered out) surfaces as a root, not dropped", () => {
const tree = buildPageTree(
[
{ id: "root", slugId: "s-root", title: "Root", position: "a0", hasChildren: true },
// parentPageId points at an id NOT in the flat list (parent filtered by perms).
{ id: "orphan", slugId: "s-orphan", title: "Orphan", position: "a1", parentPageId: "gone", hasChildren: false },
],
{ shape: "getTree" },
);
assert.deepEqual(
tree.map((n) => n.pageId).sort(),
["orphan", "root"],
);
const orphan = tree.find((n) => n.pageId === "orphan");
assert.equal("children" in orphan, false);
assert.equal("hasChildren" in orphan, false);
});
test("getTree rootPageId path: a seeded single-root subtree keeps the getTree shape", () => {
// Simulate the server seeding the CTE with the subtree root c1: the flat list
// it returns contains c1 (now a root, parent absent) + its descendant g1.
const subtree = [
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", hasChildren: true },
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", hasChildren: false },
];
const tree = buildPageTree(subtree, { shape: "getTree" });
assert.equal(tree.length, 1);
assert.equal(tree[0].pageId, "c1");
assert.deepEqual(
tree[0].children.map((n) => n.pageId),
["g1"],
);
assert.equal("slugId" in tree[0], false);
});
test("getTree maxDepth<=0 / non-finite is treated as no cut (whole tree)", () => {
for (const bad of [0, -3, NaN, Infinity, undefined]) {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: bad });
// Grandchild g1 present -> no cut applied.
assert.deepEqual(
tree[0].children[0].children.map((n) => n.pageId),
["g1"],
`maxDepth=${bad} should not cut`,
);
}
});
test("buildPageTree() with no options is byte-identical to the historic lean call", () => {
// Guard the existing callers: buildPageTree(pages) must be unchanged by the
// additive options param.
const withoutOpts = buildPageTree(SAMPLE);
const withEmptyOpts = buildPageTree(SAMPLE, {});
assert.deepEqual(withoutOpts, withEmptyOpts);
// And it is the lean {id,slugId,title,children?} shape, not the getTree shape.
assert.deepEqual(Object.keys(withoutOpts[0]).sort(), ["children", "id", "slugId", "title"]);
});