|
| 1 | +import { existsSync, readFileSync, readdirSync } from 'node:fs'; |
| 2 | +import { join, resolve } from 'node:path'; |
| 3 | +import { describe, expect, it } from 'vitest'; |
| 4 | +import { |
| 5 | + COCKPIT_DOCS_LINKS, |
| 6 | + COCKPIT_TOPICS_WITHOUT_DOCS, |
| 7 | + NO_COCKPIT_DOCS_LINK, |
| 8 | + cockpitManifest, |
| 9 | +} from '@threadplane/cockpit-registry'; |
| 10 | +import { docsConfig } from '../../../website/src/lib/docs-config'; |
| 11 | + |
| 12 | +/** |
| 13 | + * Guard for the cockpit -> website documentation links. |
| 14 | + * |
| 15 | + * `docsPath` used to be generated from a five-segment formula that matched no |
| 16 | + * route the website has ever served, so every link 404'd and nothing noticed: |
| 17 | + * the shape was asserted against a regex, never against reality. This spec |
| 18 | + * checks each declared path against the website's real content tree and its |
| 19 | + * real nav config, so a docs rename breaks a test instead of a link. |
| 20 | + */ |
| 21 | + |
| 22 | +const findWorkspaceRoot = (): string => { |
| 23 | + let dir = process.cwd(); |
| 24 | + while (dir !== resolve(dir, '..')) { |
| 25 | + if (existsSync(join(dir, 'nx.json'))) return dir; |
| 26 | + dir = resolve(dir, '..'); |
| 27 | + } |
| 28 | + throw new Error('workspace root (nx.json) not found'); |
| 29 | +}; |
| 30 | + |
| 31 | +const WORKSPACE_ROOT = findWorkspaceRoot(); |
| 32 | +const DOCS_CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content/docs'); |
| 33 | + |
| 34 | +/** Every `/docs/<library>/<section>/<slug>` the website's nav actually offers. */ |
| 35 | +const navRoutes = new Set( |
| 36 | + docsConfig.flatMap((library) => |
| 37 | + library.sections.flatMap((section) => |
| 38 | + section.pages.map((page) => `/docs/${library.id}/${section.id}/${page.slug}`) |
| 39 | + ) |
| 40 | + ) |
| 41 | +); |
| 42 | + |
| 43 | +/** Every `/docs/<library>/<section>/<slug>` backed by an `.mdx` file on disk. */ |
| 44 | +const contentRoutes = new Set<string>(); |
| 45 | +for (const library of readdirSync(DOCS_CONTENT_ROOT, { withFileTypes: true })) { |
| 46 | + if (!library.isDirectory()) continue; |
| 47 | + const libraryDir = join(DOCS_CONTENT_ROOT, library.name); |
| 48 | + for (const section of readdirSync(libraryDir, { withFileTypes: true })) { |
| 49 | + if (!section.isDirectory()) continue; |
| 50 | + const sectionDir = join(libraryDir, section.name); |
| 51 | + for (const file of readdirSync(sectionDir)) { |
| 52 | + if (!file.endsWith('.mdx')) continue; |
| 53 | + contentRoutes.add( |
| 54 | + `/docs/${library.name}/${section.name}/${file.slice(0, -'.mdx'.length)}` |
| 55 | + ); |
| 56 | + } |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +/** |
| 61 | + * Descriptors are duplicated per example (cockpit examples are standalone), so |
| 62 | + * they are read off disk rather than imported — an example whose module nobody |
| 63 | + * imports still has to declare a link that resolves. |
| 64 | + */ |
| 65 | +const readDescriptorDocsPaths = (): { file: string; key: string; docsPath: string }[] => { |
| 66 | + const results: { file: string; key: string; docsPath: string }[] = []; |
| 67 | + const cockpitRoot = join(WORKSPACE_ROOT, 'cockpit'); |
| 68 | + for (const product of readdirSync(cockpitRoot, { withFileTypes: true })) { |
| 69 | + if (!product.isDirectory()) continue; |
| 70 | + const productDir = join(cockpitRoot, product.name); |
| 71 | + for (const topic of readdirSync(productDir, { withFileTypes: true })) { |
| 72 | + if (!topic.isDirectory()) continue; |
| 73 | + for (const lane of readdirSync(join(productDir, topic.name), { withFileTypes: true })) { |
| 74 | + if (!lane.isDirectory()) continue; |
| 75 | + const file = join(productDir, topic.name, lane.name, 'src/index.ts'); |
| 76 | + if (!existsSync(file)) continue; |
| 77 | + const source = readFileSync(file, 'utf-8'); |
| 78 | + const identity = /manifestIdentity:\s*\{[^}]*?product:\s*'([^']+)'[^}]*?section:\s*'([^']+)'[^}]*?topic:\s*'([^']+)'/s.exec( |
| 79 | + source |
| 80 | + ); |
| 81 | + const declared = /\n {2}docsPath: '([^']*)',/.exec(source); |
| 82 | + if (!identity || !declared) continue; |
| 83 | + results.push({ |
| 84 | + file: file.slice(WORKSPACE_ROOT.length + 1), |
| 85 | + key: `${identity[1]}/${identity[2]}/${identity[3]}`, |
| 86 | + docsPath: declared[1], |
| 87 | + }); |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + return results; |
| 92 | +}; |
| 93 | + |
| 94 | +const descriptors = readDescriptorDocsPaths(); |
| 95 | + |
| 96 | +describe('cockpit docs links', () => { |
| 97 | + it('reads a docs route list from the website that is not empty', () => { |
| 98 | + // Guards the guard: an empty derived list would let everything below pass. |
| 99 | + expect(navRoutes.size).toBeGreaterThan(50); |
| 100 | + expect(contentRoutes.size).toBeGreaterThan(50); |
| 101 | + }); |
| 102 | + |
| 103 | + it('points every mapped capability at a page the website actually serves', () => { |
| 104 | + const broken = Object.entries(COCKPIT_DOCS_LINKS) |
| 105 | + .filter(([, path]) => path !== NO_COCKPIT_DOCS_LINK) |
| 106 | + .filter(([, path]) => !contentRoutes.has(path) || !navRoutes.has(path)) |
| 107 | + .map(([key, path]) => `${key} -> ${path}`); |
| 108 | + |
| 109 | + expect(broken).toEqual([]); |
| 110 | + }); |
| 111 | + |
| 112 | + it('blanks only the capabilities that are known to have no docs page', () => { |
| 113 | + const blanked = Object.entries(COCKPIT_DOCS_LINKS) |
| 114 | + .filter(([, path]) => path === NO_COCKPIT_DOCS_LINK) |
| 115 | + .map(([key]) => key) |
| 116 | + .sort(); |
| 117 | + |
| 118 | + expect(blanked).toEqual([...COCKPIT_TOPICS_WITHOUT_DOCS].sort()); |
| 119 | + }); |
| 120 | + |
| 121 | + it('maps every manifest entry', () => { |
| 122 | + const unmapped = cockpitManifest |
| 123 | + .filter((entry) => !(`${entry.product}/${entry.section}/${entry.topic}` in COCKPIT_DOCS_LINKS)) |
| 124 | + .map((entry) => `${entry.product}/${entry.section}/${entry.topic}`); |
| 125 | + |
| 126 | + expect(unmapped).toEqual([]); |
| 127 | + }); |
| 128 | + |
| 129 | + it('keeps every per-example descriptor in step with the shared table', () => { |
| 130 | + expect(descriptors.length).toBeGreaterThan(60); |
| 131 | + |
| 132 | + const drifted = descriptors |
| 133 | + .filter(({ key, docsPath }) => docsPath !== COCKPIT_DOCS_LINKS[key]) |
| 134 | + .map(({ file, key, docsPath }) => `${file}: ${key} declares ${docsPath || '(blank)'}`); |
| 135 | + |
| 136 | + expect(drifted).toEqual([]); |
| 137 | + }); |
| 138 | + |
| 139 | + it('declares no five-segment legacy docs path anywhere', () => { |
| 140 | + const legacy = descriptors |
| 141 | + .filter(({ docsPath }) => docsPath.split('/').filter(Boolean).length > 4) |
| 142 | + .map(({ file, docsPath }) => `${file}: ${docsPath}`); |
| 143 | + |
| 144 | + expect(legacy).toEqual([]); |
| 145 | + }); |
| 146 | +}); |
0 commit comments