#!/usr/bin/env node /** * Generates TEST-CATALOG.md by reading the test files themselves. * * Static parsing rather than running the suites: the catalogue can then be * regenerated in a second, with no database and no browser, which is the only * way a document like this stays current. * * node scripts/test-catalog.mjs write TEST-CATALOG.md * node scripts/test-catalog.mjs --check fail if it is out of date (CI) * node scripts/test-catalog.mjs --stdout print instead of writing */ import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(ROOT, 'TEST-CATALOG.md'); // --- file discovery -------------------------------------------------------- function walk(dir, match, found = []) { if (!existsSync(dir)) { return found; } for (const entry of readdirSync(dir)) { if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) { continue; } const full = join(dir, entry); if (statSync(full).isDirectory()) { walk(full, match, found); } else if (match(entry)) { found.push(full); } } return found; } const posix = (p) => relative(ROOT, p).split(sep).join('/'); // --- Go --------------------------------------------------------------------- /** Turns TestCreateBookingSellsSeats into "Create booking sells seats". */ function humanizeGo(name) { const body = name.replace(/^Test/, ''); const words = body .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .toLowerCase() .trim(); return words.charAt(0).toUpperCase() + words.slice(1); } function parseGo(file) { const src = readFileSync(file, 'utf8'); const lines = src.split('\n'); const tests = []; let current = null; let buffer = []; const flush = () => { if (!current) { return; } current.subtests = subtestsFrom(buffer.join('\n')); tests.push(current); current = null; buffer = []; }; for (const line of lines) { const start = /^func (Test\w+)\(t \*testing\.T\)/.exec(line); if (start) { flush(); current = { name: start[1], label: humanizeGo(start[1]), subtests: [] }; continue; } if (/^func /.test(line)) { flush(); continue; } if (current) { buffer.push(line); } } flush(); return tests; } /** * Pulls subtest names out of a function body. Covers the three shapes used * here: a literal t.Run, a table with a `name:` field, and a positional table * whose first column is the subtest name. */ function subtestsFrom(body) { const names = new Set(); for (const m of body.matchAll(/t\.Run\(\s*"([^"]+)"/g)) { names.add(m[1]); } for (const m of body.matchAll(/\bname:\s*"([^"]+)"/g)) { names.add(m[1]); } // Positional table rows: {"price", "CHEAPSLOW"}, const usesPositional = /t\.Run\(\s*tc\.(\w+)/.test(body) && !/\bname:/.test(body); if (usesPositional) { for (const m of body.matchAll(/^\s*\{"([^"]+)",/gm)) { names.add(m[1]); } } // A table of awkward values run by their own value, e.g. passwords. for (const m of body.matchAll(/t\.Run\(\s*(\w+),\s*func/g)) { void m; // the values are not statically knowable; counted as one case } return [...names]; } // --- TypeScript (Vitest and Playwright) ------------------------------------- /** * Walks a spec file tracking describe nesting by brace depth, so each test is * reported under the describes that contain it. */ function parseSpec(file) { const src = readFileSync(file, 'utf8'); const lines = src.split('\n'); const groups = []; const stack = []; let depth = 0; const DESCRIBE = /^\s*(?:test\.)?describe(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/; const CASE = /^\s*(?:it|test)(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/; for (const raw of lines) { const line = raw.replace(/\/\/.*$/, ''); const describe = DESCRIBE.exec(line); if (describe) { stack.push({ title: describe[2], depth }); } else { const testCase = CASE.exec(line); if (testCase) { groups.push({ suite: stack.map((s) => s.title).join(' › '), title: testCase[2] }); } } depth += (line.match(/\{/g) ?? []).length; depth -= (line.match(/\}/g) ?? []).length; while (stack.length && depth <= stack[stack.length - 1].depth) { stack.pop(); } } return groups; } // --- rendering -------------------------------------------------------------- function goSection(files) { const out = []; let functions = 0; let cases = 0; for (const file of files) { const tests = parseGo(file); if (tests.length === 0) { continue; } const fileCases = tests.reduce((n, t) => n + Math.max(1, t.subtests.length), 0); functions += tests.length; cases += fileCases; out.push(`### \`${posix(file)}\``, ''); out.push(`${tests.length} tests, ${fileCases} cases.`, ''); for (const test of tests) { out.push(`- **${test.label}** — \`${test.name}\``); for (const sub of test.subtests) { out.push(` - ${sub}`); } } out.push(''); } return { lines: out, functions, cases }; } function specSection(files, label) { const out = []; let cases = 0; for (const file of files) { const found = parseSpec(file); if (found.length === 0) { continue; } cases += found.length; out.push(`### \`${posix(file)}\``, ''); out.push(`${found.length} ${found.length === 1 ? 'test' : 'tests'}.`, ''); let lastSuite = null; for (const item of found) { if (item.suite !== lastSuite) { if (item.suite) { out.push(`- **${item.suite}**`); } lastSuite = item.suite; } out.push(item.suite ? ` - ${item.title}` : `- ${item.title}`); } out.push(''); } return { lines: out, cases, label }; } // --- assemble --------------------------------------------------------------- const goFiles = walk(join(ROOT, 'backend'), (f) => f.endsWith('_test.go')).sort(); const webFiles = walk(join(ROOT, 'frontend', 'src'), (f) => f.endsWith('.spec.ts')).sort(); const e2eFiles = walk(join(ROOT, 'e2e', 'tests'), (f) => f.endsWith('.spec.ts')).sort(); const go = goSection(goFiles); const web = specSection(webFiles, 'Angular'); const e2e = specSection(e2eFiles, 'Playwright'); const total = go.cases + web.cases + e2e.cases; const files = goFiles.length + webFiles.length + e2eFiles.length; // Specs that a second Playwright project re-runs under a mobile viewport. const REPEATED_ON_MOBILE = /(auth|responsive)\.spec\.ts$/; const mobileRepeats = e2eFiles .filter((f) => REPEATED_ON_MOBILE.test(f)) .reduce((n, f) => n + parseSpec(f).length, 0); const doc = [ '# TravelHub test catalogue', '', '> Generated by `npm run docs:tests` from the test sources.', '> Do not edit by hand — regenerate instead.', '', `**${total} test cases** across ${files} files.`, '', '| Suite | Files | Cases | Command |', '| ----- | ----: | ----: | ------- |', `| [Go](#go-backend) | ${goFiles.length} | ${go.cases} | \`npm run test:go\` |`, `| [Angular](#angular-frontend) | ${webFiles.length} | ${web.cases} | \`npm run test:web\` |`, `| [Playwright](#playwright-end-to-end) | ${e2eFiles.length} | ${e2e.cases} | \`npm run test:e2e\` |`, `| **Total** | **${files}** | **${total}** | \`npm test\` |`, '', '### Why the runners report different numbers', '', 'A *case* below is one thing being tested. The runners count differently, so', 'both figures are given rather than one being quietly preferred:', '', `- **Go** reports ${go.functions} test functions plus each table-driven subtest`, ' separately, so `go test -v` prints more `PASS` lines than there are cases', ' here. A few tables build their subtest names at runtime and cannot be', ' listed statically; those appear as a single case.', `- **Playwright** re-runs \`auth\` and \`responsive\` (${mobileRepeats} tests) under a second,`, ` mobile viewport, so a full run executes ${e2e.cases + mobileRepeats} tests from ${e2e.cases} declared.`, '- **Angular** counts exactly as listed.', '', 'Running a single file:', '', '```bash', 'cd backend && go test ./internal/repository/ -run TestCreateBookingSellsSeats -v', 'cd frontend && npx ng test --no-watch # all specs', 'cd e2e && npx playwright test tests/booking.spec.ts', '```', '', '---', '', '## Go (backend)', '', `${go.functions} test functions covering ${go.cases} cases. These run against a real`, 'PostgreSQL, with a fresh tenant created and dropped per test.', '', ...go.lines, '---', '', '## Angular (frontend)', '', `${web.cases} tests. Vitest with jsdom, via \`ng test\`.`, '', ...web.lines, '---', '', '## Playwright (end to end)', '', `${e2e.cases} tests, each run against a real browser and the real stack.`, '', ...e2e.lines, ].join('\n'); // --- output ----------------------------------------------------------------- if (process.argv.includes('--stdout')) { process.stdout.write(doc); } else if (process.argv.includes('--check')) { const existing = existsSync(OUT) ? readFileSync(OUT, 'utf8') : ''; if (existing.trim() !== doc.trim()) { console.error('✗ TEST-CATALOG.md is out of date. Run: npm run docs:tests'); process.exit(1); } console.log('✓ TEST-CATALOG.md is up to date'); } else { writeFileSync(OUT, doc + '\n', 'utf8'); console.log(`✓ wrote ${posix(OUT)} — ${total} cases`); console.log(` Go ${go.cases} · Angular ${web.cases} · Playwright ${e2e.cases}`); }