Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d1b91d0f |
@@ -59,8 +59,6 @@ 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.getPageContext(s);
|
||||
void client.listSidebarPages(s, s);
|
||||
void client.getOutline(s);
|
||||
void client.getPageJson(s);
|
||||
|
||||
@@ -23,8 +23,6 @@ type DocmostClientMethod =
|
||||
| 'getWorkspace'
|
||||
| 'getSpaces'
|
||||
| 'listPages'
|
||||
| 'getTree'
|
||||
| 'getPageContext'
|
||||
| 'listSidebarPages'
|
||||
| 'getOutline'
|
||||
| 'getPageJson'
|
||||
|
||||
+3
-108
@@ -811,17 +811,13 @@ 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): 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
|
||||
* 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
|
||||
* 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); the cursor-BFS in `enumerateSpacePages` is only a fallback for
|
||||
* stock upstream servers that lack `/pages/tree`.
|
||||
* server-side).
|
||||
*/
|
||||
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
|
||||
await this.ensureAuthenticated();
|
||||
@@ -845,107 +841,6 @@ 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Where am I / what's around" for a single page — the #443 `getPageContext`
|
||||
* tool. Metadata only (no page content), using exactly TWO server requests:
|
||||
*
|
||||
* 1. `POST /pages/breadcrumbs` — a recursive CTE that walks UP from the page.
|
||||
* The server returns the chain root->page order (it `.reverse()`s the
|
||||
* child-first walk before responding), INCLUDING the page itself as the
|
||||
* LAST element. So the last element is the page and everything before it
|
||||
* is the ancestor chain root->parent. This carries the page's own title
|
||||
* and spaceId, so no extra page-info fetch is needed for a UUID input.
|
||||
* 2. `listSidebarPages(spaceId, pageId)` — the page's DIRECT children,
|
||||
* cursor-paginated (a page with >20 children returns ALL of them, no
|
||||
* dupes) and in sidebar `position` order, each carrying `hasChildren`.
|
||||
*
|
||||
* The input may be a slugId (agents copy them from URLs); it is run through
|
||||
* `resolvePageId` first, exactly like the other page tools. A UUID input adds
|
||||
* no request there (short-circuit), keeping the total at two; a slugId input
|
||||
* adds one unavoidable resolve round-trip.
|
||||
*
|
||||
* INVARIANT: only the UUID `pageId` is exposed anywhere — server `id` is
|
||||
* mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible
|
||||
* pageId makes the server 404/403, which propagates as a clear tool error
|
||||
* (never a hollow empty object).
|
||||
*/
|
||||
async getPageContext(pageId: string) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// Resolve a possibly-slugId input to the canonical UUID (no round-trip for a
|
||||
// UUID). Errors here (bad/inaccessible id) propagate as a clear tool error.
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
// Request 1: the ancestor chain, root->page, page included as the LAST item.
|
||||
const response = await this.client.post("/pages/breadcrumbs", {
|
||||
pageId: pageUuid,
|
||||
});
|
||||
const chain: any[] = (response.data?.data ?? response.data) ?? [];
|
||||
if (!Array.isArray(chain) || chain.length === 0) {
|
||||
// The endpoint always includes the page itself, so an empty chain means
|
||||
// the page is gone/inaccessible — surface a clear error, not {}.
|
||||
throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`);
|
||||
}
|
||||
|
||||
// Split: the last element is the page, the rest (root->parent) are the
|
||||
// breadcrumbs. A root page has no ancestors -> breadcrumbs is [].
|
||||
const self = chain[chain.length - 1];
|
||||
const ancestors = chain.slice(0, -1);
|
||||
|
||||
const page = {
|
||||
pageId: self.id,
|
||||
title: self.title,
|
||||
spaceId: self.spaceId,
|
||||
};
|
||||
const breadcrumbs = ancestors.map((n: any) => ({
|
||||
pageId: n.id,
|
||||
title: n.title,
|
||||
}));
|
||||
|
||||
// Request 2: direct children in sidebar order, each with hasChildren.
|
||||
const childItems = await this.listSidebarPages(self.spaceId, pageUuid);
|
||||
const children = childItems.map((c: any) => ({
|
||||
pageId: c.id,
|
||||
title: c.title,
|
||||
hasChildren: Boolean(c.hasChildren),
|
||||
}));
|
||||
|
||||
return { page, breadcrumbs, children };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
/**
|
||||
* 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),
|
||||
* plus a server `hasChildren` boolean used by the `getTree` shape below.
|
||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
||||
*
|
||||
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
|
||||
* Output: 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
|
||||
@@ -32,14 +13,6 @@ export interface BuildPageTreeOptions {
|
||||
* 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
|
||||
@@ -53,42 +26,18 @@ export interface BuildPageTreeOptions {
|
||||
* 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[],
|
||||
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 = {
|
||||
export function buildPageTree(nodes: any[]): any[] {
|
||||
type OutputNode = {
|
||||
id: string;
|
||||
// Retained internally for shaping; never all emitted at once.
|
||||
slugId: any;
|
||||
title: any;
|
||||
hasServerChildren: boolean;
|
||||
children?: InternalNode[];
|
||||
children?: 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>();
|
||||
// Map id -> output node. Build the lean output shape up front.
|
||||
const byId = new Map<string, OutputNode>();
|
||||
// Preserve the original position string for sorting (kept off the output).
|
||||
const positionById = new Map<string, string | undefined>();
|
||||
|
||||
@@ -100,7 +49,6 @@ export function buildPageTree(
|
||||
id: node.id,
|
||||
slugId: node.slugId,
|
||||
title: node.title,
|
||||
hasServerChildren: node.hasChildren === true,
|
||||
});
|
||||
positionById.set(node.id, node.position);
|
||||
}
|
||||
@@ -142,30 +90,5 @@ export function buildPageTree(
|
||||
}
|
||||
|
||||
roots.sort(byPosition);
|
||||
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));
|
||||
return roots.map((id) => byId.get(id)!);
|
||||
}
|
||||
|
||||
@@ -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. 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). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). 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. 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,8 +82,6 @@ const TOOL_FAMILY: Record<string, Family> = {
|
||||
// READ
|
||||
search: "READ",
|
||||
listPages: "READ",
|
||||
getTree: "READ",
|
||||
getPageContext: "READ",
|
||||
listSpaces: "READ",
|
||||
getOutline: "READ",
|
||||
getNode: "READ",
|
||||
|
||||
@@ -63,8 +63,6 @@ export type DocmostClientLike = Pick<
|
||||
| 'getSpaces'
|
||||
| 'listShares'
|
||||
| 'listPages'
|
||||
| 'getTree'
|
||||
| 'getPageContext'
|
||||
| 'getPage'
|
||||
| 'getPageJson'
|
||||
| 'getOutline'
|
||||
@@ -919,13 +917,11 @@ 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. 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).',
|
||||
'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.',
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
buildShape: (z) => ({
|
||||
spaceId: z
|
||||
.string()
|
||||
@@ -958,79 +954,6 @@ 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,
|
||||
),
|
||||
},
|
||||
|
||||
getPageContext: {
|
||||
mcpName: 'getPageContext',
|
||||
inAppKey: 'getPageContext',
|
||||
description:
|
||||
'Given a pageId, get its LOCATION and immediate surroundings (metadata ' +
|
||||
'only, no page content) in one call — answers "where am I / what is ' +
|
||||
"around this page\". Returns `{ page: { pageId, title, spaceId }, " +
|
||||
'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' +
|
||||
'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' +
|
||||
'root down to the PARENT (the parent is its last element; a root page ' +
|
||||
'has `breadcrumbs: []`). `children` are the direct children in sidebar ' +
|
||||
'order, each flagged `hasChildren` so you know which can be expanded ' +
|
||||
'(descend with getTree(rootPageId=that child) or another getPageContext). ' +
|
||||
'Ids, titles and child order are consistent with getTree.',
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'The id of the page to locate (a pageId/UUID, or a slugId from a URL).',
|
||||
),
|
||||
}),
|
||||
execute: (client, { pageId }) =>
|
||||
client.getPageContext(pageId as string),
|
||||
},
|
||||
|
||||
createPage: {
|
||||
mcpName: 'createPage',
|
||||
inAppKey: 'createPage',
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I /
|
||||
// what's around" read tool. A local http.createServer stands in for Docmost
|
||||
// (same harness style as pagination-cursor.test.mjs) so everything is
|
||||
// deterministic and offline.
|
||||
//
|
||||
// Contract pinned here:
|
||||
// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page
|
||||
// INCLUDED as the LAST element) + listSidebarPages (direct children).
|
||||
// - Split: last chain element -> `page`; the rest (root->parent) ->
|
||||
// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: [].
|
||||
// - children: {pageId, title, hasChildren} in sidebar order.
|
||||
// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`.
|
||||
// - A slugId input is resolved via /pages/info first (adds one request); a
|
||||
// UUID input short-circuits (stays at two requests).
|
||||
// - A bad/inaccessible pageId throws a CLEAR error, not an empty object.
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
req.on("end", () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function startServer(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
const openServers = [];
|
||||
async function spawn(handler) {
|
||||
const { server, baseURL } = await startServer(handler);
|
||||
openServers.push(server);
|
||||
return { server, baseURL };
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
await Promise.all(openServers.map((s) => closeServer(s)));
|
||||
});
|
||||
|
||||
function handleLogin(req, res) {
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip).
|
||||
const ROOT_UUID = "00000000-0000-4000-8000-000000000001";
|
||||
const MID_UUID = "00000000-0000-4000-8000-000000000002";
|
||||
const PAGE_UUID = "00000000-0000-4000-8000-000000000003";
|
||||
const CHILD_A = "00000000-0000-4000-8000-00000000000a";
|
||||
const CHILD_B = "00000000-0000-4000-8000-00000000000b";
|
||||
|
||||
// Build a breadcrumbs response as the server sends it: root->page order, page
|
||||
// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present
|
||||
// on the wire (they must NOT leak into the tool output).
|
||||
function breadcrumbsEnvelope(chain) {
|
||||
return { success: true, data: chain };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors
|
||||
// root->parent; children mapped {pageId,title,hasChildren} in order; no leak;
|
||||
// exactly two requests for a UUID input.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => {
|
||||
let breadcrumbReqs = 0;
|
||||
let sidebarReqs = 0;
|
||||
let infoReqs = 0;
|
||||
let breadcrumbBody = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/info") {
|
||||
infoReqs++;
|
||||
sendJson(res, 404, {});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
breadcrumbReqs++;
|
||||
breadcrumbBody = JSON.parse(raw || "{}");
|
||||
// root -> parent -> page (page LAST). slugId/icon/position on the wire.
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true },
|
||||
{ id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true },
|
||||
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sidebarReqs++;
|
||||
const body = JSON.parse(raw || "{}");
|
||||
assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID");
|
||||
assert.equal(body.spaceId, "sp1", "children scoped to the page's space");
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" },
|
||||
{ id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" },
|
||||
],
|
||||
meta: { hasNextPage: false, nextCursor: null },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext(PAGE_UUID);
|
||||
|
||||
assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)");
|
||||
assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request");
|
||||
assert.equal(sidebarReqs, 1, "exactly one sidebar request");
|
||||
assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID");
|
||||
|
||||
// page = the LAST chain element.
|
||||
assert.deepEqual(result.page, {
|
||||
pageId: PAGE_UUID,
|
||||
title: "Rack 12",
|
||||
spaceId: "sp1",
|
||||
});
|
||||
// breadcrumbs = root->parent (the chain minus the page itself).
|
||||
assert.deepEqual(result.breadcrumbs, [
|
||||
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||
{ pageId: MID_UUID, title: "Datacenter A" },
|
||||
]);
|
||||
// children mapped in order, hasChildren coerced to boolean.
|
||||
assert.deepEqual(result.children, [
|
||||
{ pageId: CHILD_A, title: "Servers", hasChildren: true },
|
||||
{ pageId: CHILD_B, title: "Network", hasChildren: false },
|
||||
]);
|
||||
|
||||
// No slugId anywhere in the output.
|
||||
const dump = JSON.stringify(result);
|
||||
assert.ok(!dump.includes("Slug"), "no slugId leaks into the output");
|
||||
assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: [].
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a root page has breadcrumbs: []", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
// A root page: the CTE returns only the page itself.
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext(ROOT_UUID);
|
||||
|
||||
assert.deepEqual(result.page, {
|
||||
pageId: ROOT_UUID,
|
||||
title: "Infrastructure",
|
||||
spaceId: "sp1",
|
||||
});
|
||||
assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors");
|
||||
assert.deepEqual(result.children, [], "no children");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3) A slugId input is resolved via /pages/info first (one extra request), then
|
||||
// breadcrumbs/sidebar use the resolved UUID.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a slugId input is resolved via /pages/info", async () => {
|
||||
let infoReqs = 0;
|
||||
let infoBody = null;
|
||||
let breadcrumbBody = null;
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/info") {
|
||||
infoReqs++;
|
||||
infoBody = JSON.parse(raw || "{}");
|
||||
// getPageRaw: slugId -> canonical UUID.
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
breadcrumbBody = JSON.parse(raw || "{}");
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null },
|
||||
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext("pageSlug");
|
||||
|
||||
assert.equal(infoReqs, 1, "slugId resolved via one /pages/info");
|
||||
assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId");
|
||||
assert.deepEqual(
|
||||
breadcrumbBody,
|
||||
{ pageId: PAGE_UUID },
|
||||
"breadcrumbs posts the RESOLVED uuid, not the slugId",
|
||||
);
|
||||
assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID");
|
||||
assert.deepEqual(result.breadcrumbs, [
|
||||
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||
]);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression
|
||||
// on the #442 bug class — getPageContext must not re-introduce a cap).
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => {
|
||||
// 45 children spread over three cursor pages.
|
||||
const all = Array.from({ length: 45 }, (_, i) => ({
|
||||
id: `child-${i}`,
|
||||
slugId: `slug-${i}`,
|
||||
title: `Child ${i}`,
|
||||
parentPageId: PAGE_UUID,
|
||||
hasChildren: i % 2 === 0,
|
||||
}));
|
||||
const PAGES = {
|
||||
"": { items: all.slice(0, 20), nextCursor: "c1" },
|
||||
c1: { items: all.slice(20, 40), nextCursor: "c2" },
|
||||
c2: { items: all.slice(40), nextCursor: null },
|
||||
};
|
||||
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
const raw = await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
sendJson(
|
||||
res,
|
||||
200,
|
||||
breadcrumbsEnvelope([
|
||||
{ id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null },
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/sidebar-pages") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
items: page.items,
|
||||
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const result = await client.getPageContext(PAGE_UUID);
|
||||
|
||||
assert.equal(result.children.length, 45, "all 45 children returned");
|
||||
const ids = result.children.map((c) => c.pageId);
|
||||
assert.equal(new Set(ids).size, 45, "no duplicate children");
|
||||
assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages");
|
||||
assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)");
|
||||
assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
// Server rejects an unknown/forbidden page.
|
||||
sendJson(res, 404, { message: "Page not found" });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
await assert.rejects(
|
||||
() => client.getPageContext(PAGE_UUID),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, "throws an Error");
|
||||
return true;
|
||||
},
|
||||
"a 404 from breadcrumbs propagates as a thrown error, not a hollow {}",
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6) An empty breadcrumbs chain (should never happen — the endpoint always
|
||||
// includes the page itself) is treated as not-found, not a hollow {page:...}.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => {
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (handleLogin(req, res)) return;
|
||||
if (req.url === "/api/pages/breadcrumbs") {
|
||||
sendJson(res, 200, breadcrumbsEnvelope([]));
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, {});
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
await assert.rejects(
|
||||
() => client.getPageContext(PAGE_UUID),
|
||||
/not found or inaccessible/,
|
||||
"an empty chain is a clear error, not {}",
|
||||
);
|
||||
});
|
||||
@@ -165,70 +165,6 @@ 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: getPageContext — a page's breadcrumbs + direct children in one call.
|
||||
test("getPageContext spec exists on both hosts, builds { pageId }", () => {
|
||||
const spec = SHARED_TOOL_SPECS.getPageContext;
|
||||
assert.ok(spec, "getPageContext spec missing");
|
||||
assert.equal(spec.mcpName, "getPageContext");
|
||||
assert.equal(spec.inAppKey, "getPageContext");
|
||||
// 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(), ["pageId"]);
|
||||
const schema = z.object(shape);
|
||||
// pageId required.
|
||||
assert.doesNotThrow(() => schema.parse({ pageId: "p1" }));
|
||||
assert.throws(() => schema.parse({}));
|
||||
|
||||
// The description advertises the output shape (page/breadcrumbs/children) and
|
||||
// the root-page empty-breadcrumbs contract.
|
||||
assert.match(spec.description, /breadcrumbs/);
|
||||
assert.match(spec.description, /children/);
|
||||
assert.match(spec.description, /hasChildren/);
|
||||
assert.match(spec.description, /getTree/);
|
||||
});
|
||||
|
||||
// #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`);
|
||||
|
||||
@@ -137,164 +137,3 @@ 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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user