Files
TS_Proto/scripts/test-catalog.mjs
T
nilesh.pandClaude Opus 4.8 4d3eb18a6c Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation.

- Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool)
- Frontend: Angular 22 SPA (search, checkout, bookings, dashboard)
- Database: schema, seed data and demo bookings migrations
- Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e),
  plus a generated evidence report with PDF export
- Security: whitebox VAPT report under security/

Secrets (.env, tokens, DB credentials) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 11:38:01 +05:30

318 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}`);
}