diff --git a/tests/workspace-layout.test.mjs b/tests/workspace-layout.test.mjs new file mode 100644 index 0000000..1014863 --- /dev/null +++ b/tests/workspace-layout.test.mjs @@ -0,0 +1,190 @@ +/** + * Workspace layout test — locks in the [E00-S01-T11] apps/packages/extensions + * separation for the workspace. + * + * Acceptance criteria covered (each test fails without the committed layout): + * - "apps, packages and extensions are separated in the workspace layout" → + * the repo root contains exactly the three top-level package group + * directories apps/, packages/, extensions/; every workspace package.json + * lives exactly one level below one of them (never at the root, never in + * another directory, never nested deeper); `pnpm-workspace.yaml` declares + * one glob per group and `dependency-boundaries.json` maps the same three + * groups to the same three directories. + * - "each package sits in the correct top-level directory" → the bootstrap + * packages are found where their group says: `@personal-blog/server` in + * apps/, `@personal-blog/core` in packages/, `@personal-blog/example- + * extension` in extensions/; the discovered workspace package set is + * exactly the expected one, and every discovered package has a lockfile + * importer (layout ↔ lockfile parity). + * + * Run: `node --test tests/workspace-layout.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The three top-level package group directories of the separated layout. */ +const TOP_LEVEL_GROUPS = ['apps', 'packages', 'extensions']; + +/** Expected bootstrap packages: package name -> top-level directory it must sit in. */ +const EXPECTED_PACKAGES = { + '@personal-blog/server': 'apps/server', + '@personal-blog/core': 'packages/core', + '@personal-blog/example-extension': 'extensions/example', +}; + +/** Directories never scanned as package sources. */ +const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.pnpm-store']); + +// --------------------------------------------------------------------------- +// Layout helpers +// --------------------------------------------------------------------------- + +function isDirectory(p) { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * Recursively collects every package.json path under a directory, as a + * repo-root-relative posix path (the root workspace manifest itself is + * included as "package.json"). + */ +function collectPackageJsonPaths(dir) { + const results = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) { + results.push(...collectPackageJsonPaths(path.join(dir, entry.name))); + } + } else if (entry.name === 'package.json') { + results.push(path.relative(REPO_ROOT, path.join(dir, entry.name)).split(path.sep).join('/')); + } + } + return results; +} + +/** + * Validates the placement of a repo-root-relative package.json path. A + * workspace package must be exactly `//package.json` under one of + * the three top-level groups; the root workspace manifest ("package.json") is + * the one allowed exception. Returns an error message, or null when the + * placement is correct. + */ +function placementError(relPath) { + if (relPath === 'package.json') return null; // workspace root manifest, not a package + const parts = relPath.split('/'); + if (parts.length !== 3 || parts[2] !== 'package.json') { + return `"${relPath}" must be //package.json (exactly one level below a top-level group directory)`; + } + if (!TOP_LEVEL_GROUPS.includes(parts[0])) { + return `"${relPath}" must sit under apps/, packages/ or extensions/ (got "${parts[0]}")`; + } + return null; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('the workspace root contains exactly the three separated top-level group directories', () => { + for (const group of TOP_LEVEL_GROUPS) { + assert.ok( + isDirectory(path.join(REPO_ROOT, group)), + `top-level directory "${group}/" must exist (apps/packages/extensions separated)`, + ); + } +}); + +test('every workspace package.json sits exactly one level below its top-level group directory', () => { + const manifests = collectPackageJsonPaths(REPO_ROOT); + const packageManifests = manifests.filter((rel) => rel !== 'package.json'); + assert.ok(packageManifests.length > 0, 'the workspace must contain at least one package manifest'); + for (const rel of packageManifests) { + const error = placementError(rel); + assert.equal(error, null, error); + } +}); + +test('each expected package sits in the correct top-level directory', () => { + for (const [name, dir] of Object.entries(EXPECTED_PACKAGES)) { + const manifest = JSON.parse(read(path.join(dir, 'package.json'))); + assert.equal( + manifest.name, + name, + `"${dir}/package.json" must declare name "${name}" (each package sits in its correct top-level directory)`, + ); + } +}); + +test('the discovered workspace package set is exactly the expected bootstrap set', () => { + const manifests = collectPackageJsonPaths(REPO_ROOT).filter((rel) => rel !== 'package.json'); + const discoveredDirs = new Set(manifests.map((rel) => rel.replace(/\/package\.json$/, ''))); + assert.deepEqual(discoveredDirs, new Set(Object.values(EXPECTED_PACKAGES))); +}); + +test('pnpm-workspace.yaml declares one group glob per top-level group directory', () => { + const workspace = read('pnpm-workspace.yaml'); + const packagesSection = workspace.slice(workspace.indexOf('packages:')); + const globs = [...packagesSection.matchAll(/^\s*-\s*'([^']+)'\s*$/gm)].map((m) => m[1]); + assert.deepEqual( + globs, + TOP_LEVEL_GROUPS.map((g) => `${g}/*`), + 'pnpm-workspace.yaml must declare exactly apps/*, packages/* and extensions/*', + ); +}); + +test('dependency-boundaries.json maps the three groups to the same top-level directories', () => { + const rule = JSON.parse(read('dependency-boundaries.json')); + for (const group of TOP_LEVEL_GROUPS) { + assert.equal( + rule.groups?.[group]?.path, + `${group}/`, + `dependency-boundaries.json must map group "${group}" to path "${group}/"`, + ); + } +}); + +test('every discovered workspace package has a lockfile importer (layout ↔ lockfile parity)', () => { + const lockfile = read('pnpm-lock.yaml'); + const manifests = collectPackageJsonPaths(REPO_ROOT).filter((rel) => rel !== 'package.json'); + for (const rel of manifests) { + const importer = rel.replace(/\/package\.json$/, ''); + assert.ok( + lockfile.includes(` ${importer}:`), + `pnpm-lock.yaml must contain an importer for "${importer}" (every package in the layout must be a workspace member)`, + ); + } +}); + +test('the placement validation flags mis-placed packages (non-vacuous probe)', () => { + const valid = [ + 'apps/server/package.json', + 'packages/core/package.json', + 'extensions/example/package.json', + 'package.json', // workspace root manifest + ]; + for (const rel of valid) { + assert.equal(placementError(rel), null, `"${rel}" must be a valid placement`); + } + const invalid = [ + 'lib/shared/package.json', // no top-level group at all + 'apps/package.json', // manifest directly in a group dir (no package name level) + 'packages/a/b/package.json', // nested deeper than one level + 'docs/package.json', // outside the three groups + ]; + for (const rel of invalid) { + assert.notEqual(placementError(rel), null, `"${rel}" must be flagged as mis-placed`); + } +});