|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Pins the declared protocol-module titles (#5853) from both ends. |
| 5 | + * |
| 6 | + * The bug was `qa` rendering as **"Qa Protocol"** in three published places at |
| 7 | + * once, because `build-docs.ts` guessed the title from the directory name and |
| 8 | + * upper-cased only the three abbreviations someone had thought of |
| 9 | + * (`['UI', 'AI', 'API']`). `qa` is the fourth. |
| 10 | + * |
| 11 | + * What makes this worth more than one line of test: the wrong title was |
| 12 | + * **undetectable**. `check:docs` compares generation against what was |
| 13 | + * committed, so a stable wrong title is green forever — `Qa Protocol` survived |
| 14 | + * every regeneration from the day `src/qa/` was created until #4759 printed 14 |
| 15 | + * titles side by side and a human read it. So the pin has to cover the shape, |
| 16 | + * not just the instance: |
| 17 | + * |
| 18 | + * - **the table** — `qa` resolves to `QA Protocol`, every abbreviation module |
| 19 | + * keeps its capitalisation, and the declared keys match the directories on |
| 20 | + * disk in both directions; |
| 21 | + * - **the visibility property** the declared table was chosen for — a module |
| 22 | + * directory with no entry is REPORTED BY NAME, where the old shape silently |
| 23 | + * invented `Iam Protocol`. This is the assertion that would have to be |
| 24 | + * deleted, not merely edited, to bring the silence back; |
| 25 | + * - **the artifacts** — the three committed landing sites. Without these, |
| 26 | + * deleting the generator's use of the table would leave every unit test |
| 27 | + * green while the published pages went back to `Qa Protocol` (the same |
| 28 | + * reason `root-index.test.ts` asserts over the committed `.mdx`). |
| 29 | + */ |
| 30 | +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; |
| 31 | +import { dirname, join, resolve } from 'node:path'; |
| 32 | +import { fileURLToPath } from 'node:url'; |
| 33 | +import { describe, expect, it } from 'vitest'; |
| 34 | + |
| 35 | +import { |
| 36 | + CATEGORY_TITLES, |
| 37 | + categoryTitleCoverage, |
| 38 | + formatCategoryTitleCoverage, |
| 39 | + resolveCategoryTitles, |
| 40 | +} from './lib/category-title'; |
| 41 | + |
| 42 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 43 | +const SRC_DIR = resolve(HERE, '../src'); |
| 44 | +const DOCS_ROOT = resolve(HERE, '../../../content/docs/references'); |
| 45 | + |
| 46 | +const moduleDirsOnDisk = () => |
| 47 | + readdirSync(SRC_DIR) |
| 48 | + .filter(entry => statSync(join(SRC_DIR, entry)).isDirectory()) |
| 49 | + .sort(); |
| 50 | + |
| 51 | +describe('CATEGORY_TITLES', () => { |
| 52 | + it('titles qa as "QA Protocol" — the abbreviation the derived shape downgraded (#5853)', () => { |
| 53 | + expect(CATEGORY_TITLES.qa).toBe('QA Protocol'); |
| 54 | + expect(CATEGORY_TITLES.qa).not.toBe('Qa Protocol'); |
| 55 | + }); |
| 56 | + |
| 57 | + it('keeps every abbreviation module upper-cased, not title-cased', () => { |
| 58 | + // The four abbreviations among the module directories. `qa` sat outside the |
| 59 | + // old hard-coded trio for no reason other than nobody having added it, and |
| 60 | + // `src/qa/index.ts` itself opens with "Quality Assurance (QA) Protocol". |
| 61 | + expect({ |
| 62 | + ai: CATEGORY_TITLES.ai, |
| 63 | + api: CATEGORY_TITLES.api, |
| 64 | + qa: CATEGORY_TITLES.qa, |
| 65 | + ui: CATEGORY_TITLES.ui, |
| 66 | + }).toEqual({ |
| 67 | + ai: 'AI Protocol', |
| 68 | + api: 'API Protocol', |
| 69 | + qa: 'QA Protocol', |
| 70 | + ui: 'UI Protocol', |
| 71 | + }); |
| 72 | + }); |
| 73 | + |
| 74 | + it('declares a title for exactly the module directories under packages/spec/src', () => { |
| 75 | + // Both directions, against the real tree — the same rule `blurbCoverage` |
| 76 | + // holds the root-index blurbs to. A missing entry is the bug this file |
| 77 | + // exists for; a stale entry is a display name outliving its directory. |
| 78 | + expect(Object.keys(CATEGORY_TITLES).sort()).toEqual(moduleDirsOnDisk()); |
| 79 | + }); |
| 80 | + |
| 81 | + it('resolves the real tree without throwing, and titles every directory in it', () => { |
| 82 | + const dirs = moduleDirsOnDisk(); |
| 83 | + const resolved = resolveCategoryTitles(dirs); |
| 84 | + |
| 85 | + expect(Object.keys(resolved).sort()).toEqual(dirs); |
| 86 | + expect(Object.values(resolved).every(title => title.length > 0)).toBe(true); |
| 87 | + }); |
| 88 | +}); |
| 89 | + |
| 90 | +describe('categoryTitleCoverage — the property the declared table was chosen for', () => { |
| 91 | + const declared = { ai: 'AI Protocol', qa: 'QA Protocol' }; |
| 92 | + |
| 93 | + it('is silent when the declarations and the directories agree', () => { |
| 94 | + expect(categoryTitleCoverage(['ai', 'qa'], declared)).toEqual({ missing: [], extra: [] }); |
| 95 | + }); |
| 96 | + |
| 97 | + it('REPORTS a new module directory instead of inventing a title for it', () => { |
| 98 | + // The regression this replaces, in its next incarnation: under the old |
| 99 | + // "upper-case then look the abbreviation up in a list" shape, a new `iam` |
| 100 | + // directory silently published "Iam Protocol" and no gate could see it, |
| 101 | + // because a wrong title is a stable one. Now the run cannot produce a |
| 102 | + // title at all until someone declares it. |
| 103 | + const coverage = categoryTitleCoverage(['ai', 'iam', 'qa'], declared); |
| 104 | + |
| 105 | + expect(coverage.missing).toEqual(['iam']); |
| 106 | + expect(coverage.extra).toEqual([]); |
| 107 | + expect(() => resolveCategoryTitles(['ai', 'iam', 'qa'], declared)).toThrow(/iam/); |
| 108 | + }); |
| 109 | + |
| 110 | + it('reports a title whose directory is gone, so a display name cannot outlive it', () => { |
| 111 | + const coverage = categoryTitleCoverage(['ai'], declared); |
| 112 | + |
| 113 | + expect(coverage.missing).toEqual([]); |
| 114 | + expect(coverage.extra).toEqual(['qa']); |
| 115 | + expect(() => resolveCategoryTitles(['ai'], declared)).toThrow(/qa/); |
| 116 | + }); |
| 117 | + |
| 118 | + it('names the directory, the file to edit, and the line to add', () => { |
| 119 | + // A message that only said "coverage mismatch" would send the reader back |
| 120 | + // to the guessing shape to work out what to write. |
| 121 | + const message = formatCategoryTitleCoverage(categoryTitleCoverage(['ai', 'iam', 'qa'], declared)); |
| 122 | + |
| 123 | + expect(message).toContain('iam'); |
| 124 | + expect(message).toContain('scripts/lib/category-title.ts'); |
| 125 | + expect(message).toContain('add one line'); |
| 126 | + }); |
| 127 | +}); |
| 128 | + |
| 129 | +describe('the committed landing sites', () => { |
| 130 | + // The three places the title is published, per #5853. Read from disk rather |
| 131 | + // than regenerated: this is the assertion that fails if the generator stops |
| 132 | + // using the table, or if someone hand-edits a generated page back. |
| 133 | + const read = (rel: string) => { |
| 134 | + const abs = join(DOCS_ROOT, rel); |
| 135 | + expect(existsSync(abs), `${rel} should exist`).toBe(true); |
| 136 | + return readFileSync(abs, 'utf-8'); |
| 137 | + }; |
| 138 | + |
| 139 | + it('gives the qa category page the title "QA Protocol"', () => { |
| 140 | + expect(read('qa/index.mdx')).toContain('title: QA Protocol'); |
| 141 | + }); |
| 142 | + |
| 143 | + it('gives the qa sidebar entry the label "QA Protocol"', () => { |
| 144 | + expect(JSON.parse(read('qa/meta.json')).title).toBe('QA Protocol'); |
| 145 | + }); |
| 146 | + |
| 147 | + it('spells it "QA Protocol" in the root index nav row and section heading', () => { |
| 148 | + const index = read('index.mdx'); |
| 149 | + |
| 150 | + expect(index).toContain('[QA Protocol](/docs/references/qa)'); |
| 151 | + expect(index).toContain('## QA Protocol'); |
| 152 | + }); |
| 153 | + |
| 154 | + it('leaves no "Qa Protocol" anywhere under content/docs/references', () => { |
| 155 | + const offenders: string[] = []; |
| 156 | + const walk = (dir: string) => { |
| 157 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 158 | + const abs = join(dir, entry.name); |
| 159 | + if (entry.isDirectory()) { |
| 160 | + walk(abs); |
| 161 | + } else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.json')) { |
| 162 | + if (readFileSync(abs, 'utf-8').includes('Qa Protocol')) offenders.push(abs); |
| 163 | + } |
| 164 | + } |
| 165 | + }; |
| 166 | + walk(DOCS_ROOT); |
| 167 | + |
| 168 | + expect(offenders).toEqual([]); |
| 169 | + }); |
| 170 | +}); |
0 commit comments