diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index d9fa8aa9..e3e0e1a7 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -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); diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 2012e2b8..4dd453a9 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -23,6 +23,7 @@ type DocmostClientMethod = | 'getWorkspace' | 'getSpaces' | 'listPages' + | 'getTree' | 'listSidebarPages' | 'getOutline' | 'getPageJson' diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index 1ebb725c..70190ff5 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -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 diff --git a/packages/mcp/src/lib/tree.ts b/packages/mcp/src/lib/tree.ts index 27607295..85da178f 100644 --- a/packages/mcp/src/lib/tree.ts +++ b/packages/mcp/src/lib/tree.ts @@ -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(); + // 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(); // Preserve the original position string for sorting (kept off the output). const positionById = new Map(); @@ -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)); } diff --git a/packages/mcp/src/server-instructions.ts b/packages/mcp/src/server-instructions.ts index f79a330b..daf9ca84 100644 --- a/packages/mcp/src/server-instructions.ts +++ b/packages/mcp/src/server-instructions.ts @@ -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 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 \"#\" 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 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 \"#\" 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 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 \"#\" 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 = { // READ search: "READ", listPages: "READ", + getTree: "READ", listSpaces: "READ", getOutline: "READ", getNode: "READ", diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index 018a743a..8fc60c34 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -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', diff --git a/packages/mcp/test/unit/tool-specs.test.mjs b/packages/mcp/test/unit/tool-specs.test.mjs index e94ee222..42422e0d 100644 --- a/packages/mcp/test/unit/tool-specs.test.mjs +++ b/packages/mcp/test/unit/tool-specs.test.mjs @@ -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`); diff --git a/packages/mcp/test/unit/tree.test.mjs b/packages/mcp/test/unit/tree.test.mjs index 405464d4..a5d4b11f 100644 --- a/packages/mcp/test/unit/tree.test.mjs +++ b/packages/mcp/test/unit/tree.test.mjs @@ -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"]); +});