diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd77046..3117ed2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,11 @@ jobs: - name: Build packages run: pnpm build + - name: Migrate test database + run: pnpm db:migrate + env: + DATABASE_URL: postgresql://haip:haip@localhost:5432/haip_test + - name: Run tests and verify README test counts run: node scripts/sync-test-count.mjs --check env: diff --git a/README.md b/README.md index 1a0146e1..52ef4fcb 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 1602 Tests Passing 12 AI Agents + 1630 Tests Passing 12 AI Agents

@@ -510,7 +510,8 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1602 tests across 223 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1630 passing tests across 228 files with passing tests) | Unit and integration tests | +| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +643,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1602 tests across 223 test files) +# Passing-test count: 1630 test cases across 228 files (skipped excluded) # API tests only pnpm --filter @telivityhaip/api test @@ -1190,7 +1191,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1602 tests, 223 files) +pnpm test # Run all tests (1630 passing, 228 files with passes; skipped excluded) pnpm lint # ESLint ``` diff --git a/docs/test-stats.json b/docs/test-stats.json index 53dbb9ee..9235a5bf 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,7 @@ { - "tests": 1602, - "files": 223, - "updatedAt": "2026-08-27T10:58:08.378Z" + "tests": 1630, + "files": 228, + "scope": "all workspace packages with a test script", + "semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded", + "updatedAt": "2026-08-27T17:54:52.422Z" } diff --git a/packages/shared/src/sync-test-count-script.spec.js b/packages/shared/src/sync-test-count-script.spec.js new file mode 100644 index 00000000..56cb08bd --- /dev/null +++ b/packages/shared/src/sync-test-count-script.spec.js @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + applyCounts, + buildStatsDocument, + countPassedReport, + isReadmeOutOfDate, + selectTestPackagePaths, +} from '../../../scripts/sync-test-count.mjs'; + +const COUNTS = { tests: 10, files: 2 }; + +function syncedReadmeFixture() { + return [ + 'Apache 2.0 License', + ' 10 Tests Passing', + '', + '| Tool | Notes | Purpose |', + '| --- | --- | --- |', + '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests |', + '| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |', + '', + '# Passing-test count: 10 test cases across 2 files (skipped excluded)', + '', + 'pnpm test # Run all tests (10 passing, 2 files with passes; skipped excluded)', + '', + ].join('\n'); +} + +describe('sync-test-count workspace coverage', () => { + it('selects every non-root workspace whose manifest defines the pnpm test surface', () => { + const manifests = new Map([ + ['/repo/apps/api', { scripts: { test: 'vitest run' } }], + ['/repo/apps/dashboard', { scripts: { test: 'vitest run' } }], + ['/repo/packages/database', { scripts: { test: 'vitest run' } }], + ['/repo/packages/shared', { scripts: { test: 'vitest run' } }], + ['/repo/packages/no-tests', { scripts: { build: 'tsup' } }], + ]); + const workspaces = [ + { path: '/repo' }, + ...Array.from(manifests.keys(), (path) => ({ path })), + ]; + + expect(selectTestPackagePaths( + workspaces, + '/repo', + (path) => manifests.get(path), + )).toEqual([ + '/repo/apps/api', + '/repo/apps/dashboard', + '/repo/packages/database', + '/repo/packages/shared', + ]); + }); + + it('counts passed test cases and only files with at least one passed test', () => { + expect(countPassedReport({ + numPassedTests: 2, + testResults: [ + { assertionResults: [{ status: 'passed' }, { status: 'skipped' }] }, + { assertionResults: [{ status: 'skipped' }] }, + { assertionResults: [] }, + { assertionResults: [{ status: 'passed' }] }, + ], + })).toEqual({ tests: 2, files: 2 }); + }); + + it('publishes the test-case count scope and skipped-test semantics explicitly', () => { + expect(buildStatsDocument( + { tests: 12, files: 3 }, + '2026-08-25T12:00:00.000Z', + )).toEqual({ + tests: 12, + files: 3, + scope: 'all workspace packages with a test script', + semantics: 'passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded', + updatedAt: '2026-08-25T12:00:00.000Z', + }); + }); +}); + +describe('sync-test-count README applyCounts / --check coverage', () => { + it('is idempotent on a fully synced README (check would pass)', () => { + const readme = syncedReadmeFixture(); + expect(applyCounts(readme, COUNTS)).toBe(readme); + expect(isReadmeOutOfDate(readme, COUNTS)).toBe(false); + }); + + it('fails check when the Tests badge is stale', () => { + const stale = syncedReadmeFixture().replace( + /Tests-10%20passing-brightgreen" alt="10 Tests Passing"/, + 'Tests-9%20passing-brightgreen" alt="9 Tests Passing"', + ); + expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true); + expect(applyCounts(stale, COUNTS)).toContain('Tests-10%20passing'); + }); + + it('fails check when the Passing-test heading is stale', () => { + const stale = syncedReadmeFixture().replace( + '# Passing-test count: 10 test cases across 2 files (skipped excluded)', + '# Passing-test count: 9 test cases across 2 files (skipped excluded)', + ); + expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true); + expect(applyCounts(stale, COUNTS)).toContain( + '# Passing-test count: 10 test cases across 2 files (skipped excluded)', + ); + }); + + it('fails check when the pnpm test command count is stale', () => { + const stale = syncedReadmeFixture().replace( + 'pnpm test # Run all tests (10 passing, 2 files with passes; skipped excluded)', + 'pnpm test # Run all tests (9 passing, 2 files with passes; skipped excluded)', + ); + expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true); + expect(applyCounts(stale, COUNTS)).toContain( + 'pnpm test # Run all tests (10 passing, 2 files with passes; skipped excluded)', + ); + }); + + it('fails check when Testing and Build remain concatenated on one row', () => { + const malformed = syncedReadmeFixture().replace( + '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests |\n| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |\n', + '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |\n', + ); + expect(isReadmeOutOfDate(malformed, COUNTS)).toBe(true); + const fixed = applyCounts(malformed, COUNTS); + expect(fixed).toContain( + '| Testing | Vitest (10 passing tests across 2 files with passing tests) | Unit and integration tests |', + ); + expect(fixed).toContain( + '| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |', + ); + expect(fixed).not.toContain('tests || Build |'); + }); + + it('fails check when only the Testing table row counts are stale', () => { + const stale = syncedReadmeFixture().replace( + 'Vitest (10 passing tests across 2 files with passing tests)', + 'Vitest (9 passing tests across 2 files with passing tests)', + ); + expect(isReadmeOutOfDate(stale, COUNTS)).toBe(true); + }); +}); diff --git a/scripts/sync-test-count.mjs b/scripts/sync-test-count.mjs index b031e58d..4bce9761 100644 --- a/scripts/sync-test-count.mjs +++ b/scripts/sync-test-count.mjs @@ -1,16 +1,18 @@ #!/usr/bin/env node /** - * Run the workspace test suite and sync counts into README.md. + * Run the complete workspace test suite and sync passed counts into README.md + * and docs/test-stats.json. Skipped test cases and skipped-only files do not + * inflate the published totals. * * Usage: * node scripts/sync-test-count.mjs # run tests, update README - * node scripts/sync-test-count.mjs --check # run tests, fail if README is stale + * node scripts/sync-test-count.mjs --check # run tests, fail if published counts are stale * * Counts come from vitest's JSON reporter (reliable in CI; no log parsing). */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -19,8 +21,6 @@ const readmePath = join(root, 'README.md'); const statsPath = join(root, 'docs/test-stats.json'); const countDir = join(root, '.vitest-count'); -const TEST_PACKAGES = ['apps/api', 'apps/dashboard', 'apps/booking']; - const args = process.argv.slice(2); const checkOnly = args.includes('--check'); @@ -41,15 +41,57 @@ function countPassedFiles(report) { }).length; } +export function countPassedReport(report) { + return { + tests: report.numPassedTests ?? 0, + files: countPassedFiles(report), + }; +} + +export function buildStatsDocument(counts, updatedAt = new Date().toISOString()) { + return { + ...counts, + scope: 'all workspace packages with a test script', + semantics: + 'passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded', + updatedAt, + }; +} + +export function selectTestPackagePaths( + workspaces, + workspaceRoot = root, + readManifest = (path) => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')), +) { + return workspaces + .map((workspace) => workspace.path) + .filter((path) => resolve(path) !== resolve(workspaceRoot)) + .filter((path) => Boolean(readManifest(path)?.scripts?.test)); +} + +function discoverTestPackagePaths() { + const result = spawnSync( + 'pnpm', + ['-r', 'list', '--depth', '-1', '--json'], + { cwd: root, env: testEnv(), encoding: 'utf8' }, + ); + if (result.status !== 0) { + throw new Error( + `Could not discover pnpm test workspaces: ${result.stderr || result.stdout || 'unknown error'}`, + ); + } + return selectTestPackagePaths(JSON.parse(result.stdout)); +} + function runTestsAndCollectCounts() { mkdirSync(countDir, { recursive: true }); const env = testEnv(); let tests = 0; let files = 0; - for (const pkg of TEST_PACKAGES) { - const cwd = join(root, pkg); - const outFile = join(countDir, `${pkg.replace('/', '-')}.json`); + for (const cwd of discoverTestPackagePaths()) { + const workspacePath = relative(root, cwd); + const outFile = join(countDir, `${workspacePath.replaceAll(/[\\/]/g, '-')}.json`); const result = spawnSync( 'pnpm', @@ -61,7 +103,7 @@ function runTestsAndCollectCounts() { if (log.trim()) process.stdout.write(log); if (result.status !== 0) { - process.exit(result.status ?? 1); + throw new Error(`Tests failed in ${workspacePath}`); } if (!existsSync(outFile)) { @@ -69,8 +111,9 @@ function runTestsAndCollectCounts() { } const report = JSON.parse(readFileSync(outFile, 'utf8')); - tests += report.numPassedTests ?? 0; - files += countPassedFiles(report); + const count = countPassedReport(report); + tests += count.tests; + files += count.files; } if (tests === 0 || files === 0) { @@ -80,7 +123,11 @@ function runTestsAndCollectCounts() { return { tests, files }; } -function applyCounts(readme, { tests, files }) { +/** + * Rewrite every README location owned by this synchronizer (badge, Testing + * table row, malformed-row normalization, heading, and `pnpm test` command). + */ +export function applyCounts(readme, { tests, files }) { let next = readme; if (!next.includes('img.shields.io/badge/Tests-')) { @@ -95,54 +142,67 @@ function applyCounts(readme, { tests, files }) { ); } + // Normalize legacy rows where Testing and Build were concatenated on one line. + next = next.replace( + /\| Testing \| Vitest \([^|]+\) \| Unit and integration tests \|\| Build \|[^|\n]+\|[^\n]*\n/, + `| Testing | Vitest (${tests} passing tests across ${files} files with passing tests) | Unit and integration tests |\n| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |\n`, + ); + next = next.replace( /\| Testing \| Vitest \([^|]+\) \| Unit and integration tests \|/, - `| Testing | Vitest (${tests} tests across ${files} test files) | Unit and integration tests |`, + `| Testing | Vitest (${tests} passing tests across ${files} files with passing tests) | Unit and integration tests |`, ); next = next.replace( - /# All tests[^\n]*/, - `# All tests (${tests} tests across ${files} test files)`, + /(?:# All tests|# Passing-test count:)[^\n]*/, + `# Passing-test count: ${tests} test cases across ${files} files (skipped excluded)`, ); next = next.replace( /pnpm test\s+# Run all tests[^\n]*/, - `pnpm test # Run all tests (${tests} tests, ${files} files)`, + `pnpm test # Run all tests (${tests} passing, ${files} files with passes; skipped excluded)`, ); return next; } -function readCountsFromReadme(readme) { - const testsMatch = readme.match( - /\| Testing \| Vitest \((\d+) tests across (\d+) test files\) \|/, - ); - if (!testsMatch) { - throw new Error('README is missing synced test counts — run: pnpm readme:sync-tests'); - } - return { tests: Number(testsMatch[1]), files: Number(testsMatch[2]) }; +/** True when any synchronizer-owned README location differs from applyCounts output. */ +export function isReadmeOutOfDate(readme, counts) { + return applyCounts(readme, counts) !== readme; } -const counts = runTestsAndCollectCounts(); -const readme = readFileSync(readmePath, 'utf8'); - -if (checkOnly) { - const current = readCountsFromReadme(readme); - if (current.tests !== counts.tests || current.files !== counts.files) { - console.error( - `README test counts are stale (readme: ${current.tests} tests / ${current.files} files, actual: ${counts.tests} / ${counts.files}).`, - ); - console.error('Run: pnpm readme:sync-tests'); - process.exit(1); +function main() { + const counts = runTestsAndCollectCounts(); + const readme = readFileSync(readmePath, 'utf8'); + + if (checkOnly) { + const stats = JSON.parse(readFileSync(statsPath, 'utf8')); + const expectedStats = buildStatsDocument(counts, stats.updatedAt); + const readmeStale = isReadmeOutOfDate(readme, counts); + const statsStale = JSON.stringify(stats) !== JSON.stringify(expectedStats); + if (readmeStale || statsStale) { + throw new Error( + `Published test counts are stale (actual: ${counts.tests} / ${counts.files}). Run: pnpm readme:sync-tests`, + ); + } + console.log(`Published test counts OK (${counts.tests} tests, ${counts.files} files)`); + return; } - console.log(`README test counts OK (${counts.tests} tests, ${counts.files} files)`); - process.exit(0); -} -writeFileSync( - statsPath, - `${JSON.stringify({ ...counts, updatedAt: new Date().toISOString() }, null, 2)}\n`, -); + writeFileSync( + statsPath, + `${JSON.stringify(buildStatsDocument(counts), null, 2)}\n`, + ); + + writeFileSync(readmePath, applyCounts(readme, counts)); + console.log(`Synced README test counts: ${counts.tests} tests across ${counts.files} files`); +} -writeFileSync(readmePath, applyCounts(readme, counts)); -console.log(`Synced README test counts: ${counts.tests} tests across ${counts.files} files`); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +}