diff --git a/README.md b/README.md index 001fa2e..20e1b95 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ When creating a scoped token, select the following [classic scopes](https://deve | `write:confluence-file` | Uploading attachments (`attachments --upload`) | | `write:confluence-space` | Managing spaces | -For **read-only** usage, select at minimum: `read:confluence-content.all`, `read:confluence-content.summary`, `read:confluence-space.summary`, and `search:confluence`. +For **read-only** usage, select at minimum the classic scopes `read:confluence-content.all`, `read:confluence-content.summary`, `read:confluence-space.summary`, and `search:confluence`. Listing folders with `children --type folders`/`all` additionally requires the granular scope `read:hierarchical-content:confluence`. **On-premise / Data Center:** Use your Confluence username and password for basic authentication. @@ -580,28 +580,36 @@ confluence spaces --limit 2000 confluence spaces --all ``` -### List Child Pages +### List Children ```bash # List direct child pages confluence children 123456789 -# List all descendants recursively +# List descendant pages recursively confluence children 123456789 --recursive +# List child folders instead of pages (Confluence Cloud only) +confluence children 123456789 --type folders + +# List both pages and folders; each item shows its content type +confluence children 123456789 --type all + # Display as tree structure confluence children 123456789 --recursive --format tree -# Show page IDs and URLs +# Show child IDs and available URLs confluence children 123456789 --show-id --show-url -# Limit recursion depth +# Limit page recursion depth confluence children 123456789 --recursive --max-depth 3 # Output as JSON for scripting confluence children 123456789 --recursive --json > children.json ``` -`children --json` returns structured metadata for each page, including `id`, `title`, `type`, `status`, `spaceKey`, `parentId`, `version`, and `url`. Recursive output also includes `depth`, and when available, `ancestors`. +`children --json` returns structured metadata for each child, including `id`, `title`, `type`, `status`, `spaceKey`, `parentId`, `version`, and `url`. Recursive page entries also include `depth`, and when available, `ancestors`. + +Use `--type` to choose what is listed: `pages` (default, unchanged behavior), `folders`, or `all`. The `type` field on each item distinguishes `page` from `folder`; when folders can appear, the human-readable list marks every item with `[page]` or `[folder]`, and tree output uses distinct page and folder icons. Folders are a Confluence Cloud concept; on Server/Data Center, `--type folders`/`all` prints a warning and skips folders instead of failing, while `all` still lists pages. When pages are included, `--recursive` lists their descendants as before, but folders remain limited to direct children of the requested page. Example recursive JSON item: ```json @@ -864,7 +872,7 @@ confluence stats | `search ` | Search for pages | `--json`, `--limit `, `--start ` | | `spaces` | List available spaces | `--json`, `--limit `, `--all` | | `find ` | Find a page by its title | `--space <spaceKey>`, `--json` | -| `children <pageId>` | List child pages of a page | `--recursive`, `--max-depth <number>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` | +| `children <pageId>` | List child pages and folders of a page | `--recursive`, `--max-depth <number>`, `--type <pages\|folders\|all>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` | | `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` | | `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` | | `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet`, `--json` | diff --git a/bin/confluence.js b/bin/confluence.js index fa2c2d9..fd89a7f 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -779,30 +779,51 @@ program // List children command program .command('children <pageId>') - .description('List child pages of a Confluence page') - .option('-r, --recursive', 'List all descendants recursively', false) - .option('--max-depth <number>', 'Maximum depth for recursive listing', '10') + .description('List child pages and folders of a Confluence page') + .option('-r, --recursive', 'Recurse through descendant pages; folders remain direct children', false) + .option('--max-depth <number>', 'Maximum page recursion depth', '10') + .option('--type <type>', 'Content type to list: pages, folders, all (folders are Cloud-only)', 'pages') .option('--format <format>', 'Output format (list, tree). "json" is deprecated — use --json', 'list') - .option('--show-url', 'Show page URLs', false) - .option('--show-id', 'Show page IDs', false) + .option('--show-url', 'Show available child URLs', false) + .option('--show-id', 'Show child IDs', false) .action(withClient('children', async ({ client, config, analytics, wantsJson, emitJson }, pageId, options) => { const format = (options.format || 'list').toLowerCase(); const jsonMode = wantsJson(options); + const type = (options.type || 'pages').toLowerCase(); + if (!['pages', 'folders', 'all'].includes(type)) { + throw new Error(`Invalid --type "${options.type}". Valid values are: pages, folders, all.`); + } + const includePages = type === 'pages' || type === 'all'; + const includeFolders = type === 'folders' || type === 'all'; + // Extract page ID from URL if needed const resolvedPageId = await client.extractPageId(pageId); - // Get children - let children; - if (options.recursive) { - const maxDepth = parseInt(options.maxDepth) || 10; - children = await client.getAllDescendantPages( - resolvedPageId, - maxDepth, - { includeAncestors: jsonMode } - ); - } else { - children = await client.getChildPages(resolvedPageId); + // Get child pages + let children = []; + if (includePages) { + if (options.recursive) { + const maxDepth = parseInt(options.maxDepth) || 10; + children = await client.getAllDescendantPages( + resolvedPageId, + maxDepth, + { includeAncestors: jsonMode } + ); + } else { + children = await client.getChildPages(resolvedPageId); + } + } + + // Get child folders (Confluence Cloud only). On Server/DC there is no + // folder content type, so degrade gracefully instead of crashing. + if (includeFolders) { + if (client.isCloud()) { + const folders = await client.getChildFolders(resolvedPageId); + children = children.concat(folders); + } else { + console.error(chalk.yellow('Folders are only supported on Confluence Cloud; none were listed.')); + } } if (children.length === 0) { @@ -813,7 +834,8 @@ program children: [] }); } else { - console.log(chalk.yellow('No child pages found.')); + const emptyNoun = type === 'folders' ? 'child folders' : type === 'all' ? 'children' : 'child pages'; + console.log(chalk.yellow(`No ${emptyNoun} found.`)); } analytics.track('children', true); return; @@ -858,21 +880,26 @@ program printTree(tree, client, config, options, 1); console.log(''); - console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`)); + console.log(chalk.gray(`Total: ${children.length} ${totalLabel(type, children.length)}`)); } else { // List format (default) - console.log(chalk.blue('Child pages:')); + console.log(chalk.blue(type === 'pages' ? 'Child pages:' : 'Children:')); console.log(''); children.forEach((page, index) => { let output = `${index + 1}. ${chalk.green(page.title)}`; + // Indicate the content type whenever folders may appear in the output. + if (includeFolders) { + output += ` ${chalk.cyan(`[${page.type === 'folder' ? 'folder' : 'page'}]`)}`; + } + if (options.showId) { output += ` ${chalk.gray(`(ID: ${page.id})`)}`; } - if (options.showUrl) { - const url = `${client.buildUrl(`${client.webUrlPrefix}/spaces/${page.space?.key}/pages/${page.id}`)}`; + const url = options.showUrl ? childUrl(page, client) : null; + if (url) { output += `\n ${chalk.gray(url)}`; } @@ -884,12 +911,27 @@ program }); console.log(''); - console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`)); + console.log(chalk.gray(`Total: ${children.length} ${totalLabel(type, children.length)}`)); } analytics.track('children', true); })); +// Pluralized noun for the "Total: N ..." summary line. Keeps the historical +// "child page(s)" wording for the default (pages) type for byte-compatibility. +function totalLabel(type, count) { + const noun = type === 'pages' ? 'child page' : 'item'; + return `${noun}${count === 1 ? '' : 's'}`; +} + +function childUrl(child, client) { + if (child.type === 'folder') { + return child.url || null; + } + + return client.buildUrl(`${client.webUrlPrefix}/spaces/${child.space?.key}/pages/${child.id}`); +} + // Helper function to build tree structure function buildTree(pages, rootId) { const tree = []; @@ -925,14 +967,15 @@ function printTree(nodes, client, config, options, depth = 1) { const indent = ' '.repeat(depth - 1); const prefix = isLast ? '└── ' : '├── '; - let output = `${indent}${prefix}📄 ${chalk.green(node.title)}`; + const icon = node.type === 'folder' ? '📁' : '📄'; + let output = `${indent}${prefix}${icon} ${chalk.green(node.title)}`; if (options.showId) { output += ` ${chalk.gray(`(ID: ${node.id})`)}`; } - if (options.showUrl) { - const url = `${client.buildUrl(`${client.webUrlPrefix}/spaces/${node.space?.key}/pages/${node.id}`)}`; + const url = options.showUrl ? childUrl(node, client) : null; + if (url) { output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`; } diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 623e107..98137e6 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -166,7 +166,7 @@ class ConfluenceClient { if (this.isScopedToken()) { hints.push( 'You are using a scoped API token (api.atlassian.com). Please verify:', - ' - Your token has the required scopes (e.g., read:confluence-content.all, read:confluence-content.summary, read:confluence-space.summary)', + ' - Your token has the required scopes (e.g., read:confluence-content.all, read:confluence-content.summary, read:confluence-space.summary, read:hierarchical-content:confluence)', ' - Your Cloud ID in the API path is correct', ' - Your email matches the account that created the token', 'See: https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/' @@ -1766,6 +1766,64 @@ class ConfluenceClient { })); } + /** + * Base URL for the Confluence Cloud v2 REST API. + * + * Folders are a Cloud-only content type and are not exposed by the v1 + * `/content/{id}/child/*` endpoints, so folder listing is served by v2. + * The v2 API lives alongside v1: `/wiki/rest/api` -> `/wiki/api/v2`. + */ + v2BaseUrl() { + const v2Path = /\/rest\/api$/.test(this.apiPath) + ? this.apiPath.replace(/\/rest\/api$/, '/api/v2') + : `${this.apiPath.replace(/\/+$/, '')}/api/v2`; + return `${this.protocol}://${this.domain}${v2Path}`; + } + + /** + * Get direct child folders of a page (Confluence Cloud only). + * + * Uses the v2 `/pages/{id}/direct-children` endpoint, which returns children + * of every content type, and keeps only folders. Passing an absolute URL to + * the shared axios instance reuses its auth headers and TLS configuration + * while bypassing the v1 baseURL. + */ + async getChildFolders(pageId, limit = 250) { + const url = `${this.v2BaseUrl()}/pages/${pageId}/direct-children`; + const results = []; + let cursor = null; + + do { + const params = cursor ? { limit, cursor } : { limit }; + const response = await this.client.get(url, { params }); + results.push(...(response.data?.results || [])); + cursor = this.parseNextCursor(response.data?._links?.next); + } while (cursor); + + return results + .filter(item => item.type === 'folder') + .map(item => this.normalizeFolder(item, pageId)); + } + + /** + * Normalize a v2 folder record into the same shape used for child pages so + * the two can be listed together. Folders have no storage body, space key, + * version, or web URL exposed by the direct-children endpoint. + */ + normalizeFolder(raw, parentId) { + const id = raw?.id !== undefined && raw?.id !== null ? String(raw.id) : null; + return { + id, + title: raw?.title || '', + type: 'folder', + status: raw?.status || null, + spaceKey: null, + parentId: parentId !== undefined && parentId !== null ? String(parentId) : null, + version: null, + url: null + }; + } + /** * Get all descendant pages recursively */ @@ -2193,6 +2251,18 @@ class ConfluenceClient { return Number.isNaN(value) ? null : value; } + parseNextCursor(nextLink) { + if (!nextLink) { + return null; + } + + try { + return new URL(nextLink, this.v2BaseUrl()).searchParams.get('cursor'); + } catch { + return null; + } + } + parsePositiveInt(value, fallback) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed) || parsed < 0) { diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 3a0986d..64453e1 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -76,6 +76,8 @@ Required classic scopes for scoped tokens: - Write: add `write:confluence-content`, `write:confluence-file`, `write:confluence-space` - Attachments: `readonly:content.attachment:confluence` (download), `write:confluence-file` (upload) +Folder listing with `children --type folders`/`all` also requires the granular scope `read:hierarchical-content:confluence`. + **Read-only mode (recommended for AI agents):** Prevents all write operations (create, update, delete, move, etc.) at the profile level. Useful when giving an AI agent access to Confluence for reading only. @@ -239,26 +241,31 @@ confluence spaces ### `children <pageId>` -List child pages of a page. +List child pages and Confluence Cloud folders of a page. ```sh -confluence children <pageId> [--recursive] [--max-depth <number>] [--format list|tree|json] [--show-id] [--show-url] +confluence children <pageId> [--recursive] [--max-depth <number>] [--type pages|folders|all] [--format list|tree] [--json] [--show-id] [--show-url] ``` | Option | Default | Description | |---|---|---| -| `--recursive` | false | List all descendants recursively | -| `--max-depth` | `10` | Maximum depth for recursive listing | -| `--format` | `list` | Output format: `list`, `tree`, or `json` | -| `--show-id` | false | Show page IDs | -| `--show-url` | false | Show page URLs | +| `--recursive` | false | Recurse through descendant pages; folders remain limited to direct children | +| `--max-depth` | `10` | Maximum page recursion depth | +| `--type` | `pages` | Content type to list: `pages`, `folders`, or `all` (folders are Confluence Cloud only) | +| `--format` | `list` | Human-readable output format: `list` or `tree` | +| `--json` | false | Emit structured JSON | +| `--show-id` | false | Show child IDs | +| `--show-url` | false | Show available child URLs | ```sh confluence children 123456789 -confluence children 123456789 --recursive --format json +confluence children 123456789 --recursive --json confluence children 123456789 --recursive --format tree --show-id +confluence children 123456789 --type all ``` +The default `pages` mode preserves the existing output. In `folders` and `all` modes, list output tags every item as `[page]` or `[folder]`, and tree output uses distinct page and folder icons. On Server/Data Center, folder modes warn instead of failing; `folders` produces an empty result, while `all` still lists pages. + --- ### `create <title> <spaceKey>` @@ -779,7 +786,7 @@ confluence export 123456789 --format markdown --dest ./local-docs ### Process children as JSON ```sh -confluence children 123456789 --recursive --format json | jq '.[].id' +confluence children 123456789 --recursive --json | jq '.children[].id' ``` ### Search and process results @@ -794,7 +801,7 @@ confluence search --cql 'siteSearch ~ "release notes" and space = "MYSPACE"' --l - **Always use `--yes`** on destructive commands (`delete`, `comment-delete`, `attachment-delete`) to avoid interactive prompts blocking the agent. - **Prefer `--format markdown`** when creating or updating content from agent-generated text — it's the most natural format and the API converts it automatically. -- **Use `--format json`** on `children` and `comments` for machine-parseable output. +- **Use `--json`** on `children` and `comments` for machine-parseable output. - **ANSI color codes**: stdout may contain ANSI escape sequences. Pipe through `| cat` or use `NO_COLOR=1` if your downstream tool doesn't handle them. - **Page ID vs URL**: when you have a Confluence URL, extract `?pageId=<number>` and pass the number. Do not pass pretty/display URLs — they are not supported. - **Cross-space moves**: `confluence move` only works within the same space. Moving across spaces is not supported. diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index a60ae21..9cc5897 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -512,6 +512,7 @@ describe('ConfluenceClient', () => { await expect(scopedClient.readPage('123')).rejects.toThrow(/scoped API token/); await expect(scopedClient.readPage('123')).rejects.toThrow(/read:confluence-content\.all/); + await expect(scopedClient.readPage('123')).rejects.toThrow(/read:hierarchical-content:confluence/); mock.restore(); }); @@ -1149,6 +1150,56 @@ describe('ConfluenceClient', () => { mock.restore(); }); + + test('getChildFolders follows v2 pagination and keeps only folders', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('https://test.atlassian.net/api/v2/pages/123/direct-children').reply(config => { + expect(config.params.limit).toBe(250); + if (!config.params.cursor) { + return [200, { + results: [ + { id: '200', title: 'Child Page', type: 'page', status: 'current' }, + { id: '400', title: 'Docs Folder', type: 'folder', status: 'current' } + ], + _links: { next: '/api/v2/pages/123/direct-children?cursor=next-page' } + }]; + } + expect(config.params.cursor).toBe('next-page'); + return [200, { + results: [ + { id: '500', title: 'A Whiteboard', type: 'whiteboard', status: 'current' }, + { id: '600', title: 'Runbooks Folder', type: 'folder', status: 'current' } + ], + _links: {} + }]; + }); + + const folders = await client.getChildFolders('123'); + expect(folders).toEqual([ + { + id: '400', + title: 'Docs Folder', + type: 'folder', + status: 'current', + spaceKey: null, + parentId: '123', + version: null, + url: null + }, + { + id: '600', + title: 'Runbooks Folder', + type: 'folder', + status: 'current', + spaceKey: null, + parentId: '123', + version: null, + url: null + } + ]); + + mock.restore(); + }); }); describe('extractPageId', () => { diff --git a/tests/metadata-cli.test.js b/tests/metadata-cli.test.js index dfa550b..1b83806 100644 --- a/tests/metadata-cli.test.js +++ b/tests/metadata-cli.test.js @@ -27,7 +27,9 @@ describe('CLI metadata and storage output', () => { getPageInfo: jest.fn(), extractPageId: jest.fn(async (pageId) => String(pageId)), getChildPages: jest.fn(), + getChildFolders: jest.fn(async () => []), getAllDescendantPages: jest.fn(), + isCloud: jest.fn(() => true), buildUrl: jest.fn((value) => value), webUrlPrefix: '/wiki', ...clientOverrides @@ -211,6 +213,152 @@ describe('CLI metadata and storage output', () => { expect(output.children[0].ancestors).toBeUndefined(); }); + test('children defaults to pages and does not query folders', async () => { + const { program, client } = await loadCli({ + getChildPages: jest.fn(async () => ([ + { id: '200', title: 'Child Page', type: 'page', status: 'current', parentId: '123' } + ])) + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['children', '123', '--format', 'json']); + + expect(client.getChildPages).toHaveBeenCalledWith('123'); + expect(client.getChildFolders).not.toHaveBeenCalled(); + const output = JSON.parse(logSpy.mock.calls[0][0]); + expect(output.children.map((c) => c.type)).toEqual(['page']); + }); + + test('children --type folders lists only folders with type indication', async () => { + const { program, client } = await loadCli({ + getChildFolders: jest.fn(async () => ([ + { + id: '400', + title: 'Docs Folder', + type: 'folder', + status: 'current', + spaceKey: null, + parentId: '123', + version: null, + url: null + } + ])) + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['children', '123', '--type', 'folders', '--format', 'json']); + + expect(client.getChildPages).not.toHaveBeenCalled(); + expect(client.getChildFolders).toHaveBeenCalledWith('123'); + const output = JSON.parse(logSpy.mock.calls[0][0]); + expect(output).toEqual({ + pageId: '123', + childCount: 1, + children: [ + { + id: '400', + title: 'Docs Folder', + type: 'folder', + status: 'current', + spaceKey: null, + parentId: '123', + version: null, + url: null + } + ] + }); + }); + + test('children --type all lists pages and folders together', async () => { + const { program, client } = await loadCli({ + getChildPages: jest.fn(async () => ([ + { id: '200', title: 'Child Page', type: 'page', status: 'current', spaceKey: 'ENG', parentId: '123', version: 4, url: null } + ])), + getChildFolders: jest.fn(async () => ([ + { id: '400', title: 'Docs Folder', type: 'folder', status: 'current', spaceKey: null, parentId: '123', version: null, url: null } + ])) + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['children', '123', '--type', 'all', '--format', 'json']); + + expect(client.getChildPages).toHaveBeenCalledWith('123'); + expect(client.getChildFolders).toHaveBeenCalledWith('123'); + const output = JSON.parse(logSpy.mock.calls[0][0]); + expect(output.childCount).toBe(2); + expect(output.children.map((c) => c.type)).toEqual(['page', 'folder']); + }); + + test('children --type folders on non-Cloud warns in JSON mode and lists nothing', async () => { + const { program, client } = await loadCli({ + isCloud: jest.fn(() => false) + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await runCli(program, ['--json', 'children', '123', '--type', 'folders']); + + expect(client.getChildFolders).not.toHaveBeenCalled(); + const warnings = errorSpy.mock.calls.map((call) => stripAnsi(call[0])); + expect(warnings.some((line) => line.includes('only supported on Confluence Cloud'))).toBe(true); + expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({ + pageId: '123', + childCount: 0, + children: [] + }); + }); + + test('children --show-url omits unavailable folder URLs', async () => { + const { program, client } = await loadCli({ + getChildFolders: jest.fn(async () => ([ + { id: '400', title: 'Docs Folder', type: 'folder', parentId: '123', url: null } + ])) + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['children', '123', '--type', 'folders', '--show-url']); + + const output = logSpy.mock.calls.map((call) => stripAnsi(call[0])).join('\n'); + expect(output).toContain('Docs Folder [folder]'); + expect(output).not.toContain('/spaces/undefined/'); + expect(client.buildUrl).not.toHaveBeenCalled(); + }); + + test('children --show-url preserves legacy page URLs', async () => { + const { program, client } = await loadCli({ + getChildPages: jest.fn(async () => ([ + { + id: '200', + title: 'Child Page', + type: 'page', + space: { key: 'ENG' }, + url: 'https://test.atlassian.net/wiki/spaces/ENG/pages/200/Child+Page' + } + ])) + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['children', '123', '--show-url']); + + expect(client.buildUrl).toHaveBeenCalledWith('/wiki/spaces/ENG/pages/200'); + const output = logSpy.mock.calls.map((call) => stripAnsi(call[0])).join('\n'); + expect(output).toContain('/wiki/spaces/ENG/pages/200'); + expect(output).not.toContain('/Child+Page'); + }); + + test('children rejects an invalid --type value', async () => { + const { program } = await loadCli(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(runCli(program, ['children', '123', '--type', 'bogus'])).rejects.toThrow('process.exit called'); + expect(exitSpy).toHaveBeenCalledWith(1); + const errors = errorSpy.mock.calls.map((call) => stripAnsi(call.join(' '))); + expect(errors.some((line) => line.includes('Invalid --type'))).toBe(true); + }); + test('children --recursive --format json includes depth and ancestors', async () => { const getAllDescendantPages = jest.fn(async () => ([ {