/** * aggregate-component-registry — the ENVELOPE contract of the real entry point * (QC 2026-09-24, D9). Two defects, both invisible to the in-process tests * because they live in index.ts and on the write path: * * 1. `--out ../escape.ts` — safeJoinPath threw FileSystemError OUTSIDE any * try: raw stack trace on stderr, EMPTY stdout, exit 1, no envelope. Every * error thrown on the generate/write path is now answered as a JSON * envelope (`success:false`, `errors[]` naming the path) on stdout. * 2. A valid web root with ZERO per-module registries answered success:true, * warnings:[] and WROTE an empty componentRegistry.generated.ts (B-WARN-15). * Now: success:true (nothing is wrong with the project), ONE warning naming * the remedy, nextSteps naming it too, and NO file touched. * * FIRES cases spawn the real index.ts (node --import tsx, from the repo root); * the SILENT case aggregates a registry emitted by scaffold-routes' own * generate() — never a hand-typed mimic. `JourFerie` because its plural * (`JourFeries`) is not a bare `+s` on the section name. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { spawnSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { generate } from '../generate.js' import { generate as scaffoldRoutes } from '../../scaffold-routes/generate.js' import { ScaffoldRoutesInputSchema } from '../../scaffold-routes/types.js' import { PAGE_REGISTER_RE } from '../../../../../../lib/routes-registry.js' const HERE = fileURLToPath(new URL('.', import.meta.url)) const INDEX = path.resolve(HERE, '..', 'index.ts') /** __tests__ → aggregate-component-registry → cli → routes → frontend → development → skills → templates → repo root */ const REPO_ROOT = path.resolve(HERE, '../../../../../../../..') interface Envelope { success: boolean command: string filesCreated: string[] errors: string[] warnings: string[] nextSteps: string[] data?: Record } function run(args: string[]): { code: number; stdout: string; stderr: string; envelope: Envelope } { const r = spawnSync(process.execPath, ['--import', 'tsx', INDEX, ...args], { cwd: REPO_ROOT, encoding: 'utf-8', env: { ...process.env, NO_COLOR: '1' }, }) const stdout = r.stdout ?? '' const stderr = r.stderr ?? '' let envelope: Envelope try { envelope = JSON.parse(stdout) as Envelope } catch { throw new Error(`stdout is not a JSON envelope (exit ${r.status}).\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`) } return { code: r.status ?? -1, stdout, stderr, envelope } } let web: string beforeEach(() => { web = fs.mkdtempSync(path.join(os.tmpdir(), 'agg-envelope-')) // A VALID web root: package.json + an (empty) src/extensions — assertWebProjectRoot passes. fs.writeFileSync(path.join(web, 'package.json'), '{ "name": "fixture-web", "private": true }\n', 'utf-8') fs.mkdirSync(path.join(web, 'src', 'extensions'), { recursive: true }) }) afterEach(() => { fs.rmSync(web, { recursive: true, force: true }) }) function write(rel: string, content: string): void { const abs = path.join(web, rel) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, content, 'utf-8') } const AGGREGATE = 'src/extensions/componentRegistry.generated.ts' describe('index.ts — a thrown error is an envelope, never a stack trace (FIRES)', () => { it('`--out ../escape.ts` → success:false, errors[0] names the path, exit 1, no stack on stderr, nothing written', () => { const { code, stderr, envelope } = run(['--project-path', web, '--out', '../escape.ts']) expect(code).toBe(1) expect(envelope.success).toBe(false) expect(envelope.command).toBe('aggregate-component-registry') expect(envelope.errors).toHaveLength(1) expect(envelope.errors[0]).toContain('Path traversal detected') expect(envelope.errors[0]).toContain('escape.ts') expect(envelope.errors[0]).toContain('--out must stay under --project-path') expect(envelope.filesCreated).toEqual([]) // No stack frame leaked: neither the error class name nor an `at …` line. expect(stderr).not.toContain('FileSystemError') expect(stderr).not.toMatch(/^\s+at /m) expect(fs.existsSync(path.join(web, '..', 'escape.ts'))).toBe(false) expect(fs.existsSync(path.join(web, AGGREGATE))).toBe(false) }, 60_000) }) describe('index.ts — zero per-module registries under a valid web root (FIRES)', () => { it('write mode → success:true, ONE warning naming the remedy, nextSteps too, and NO file touched', () => { const { code, envelope } = run(['--project-path', web]) expect(code).toBe(0) expect(envelope.success).toBe(true) expect(envelope.errors).toEqual([]) expect(envelope.warnings).toHaveLength(1) expect(envelope.warnings[0]).toMatch(/0 per-module registries/) expect(envelope.warnings[0]).toContain('nothing aggregated') expect(envelope.warnings[0]).toContain('scaffold-routes') expect(envelope.warnings[0]).toContain('--project-path') expect(envelope.nextSteps.join('\n')).toContain('scaffold-routes') expect(envelope.data?.modulesAggregated).toBe(0) expect(envelope.filesCreated).toEqual([]) // The defect: an EMPTY aggregate used to be written here with warnings:[]. expect(fs.existsSync(path.join(web, AGGREGATE))).toBe(false) expect(fs.readdirSync(path.join(web, 'src', 'extensions'))).toEqual([]) }, 60_000) it('--dry-run → the same warning and remedy, no file', () => { const { code, envelope } = run(['--project-path', web, '--dry-run']) expect(code).toBe(0) expect(envelope.success).toBe(true) expect(envelope.warnings).toHaveLength(1) expect(envelope.warnings[0]).toMatch(/0 per-module registries/) expect(envelope.nextSteps.join('\n')).toContain('scaffold-routes') expect(envelope.data?.dryRun).toBe(true) expect(envelope.data?.files).toEqual([]) expect(fs.existsSync(path.join(web, AGGREGATE))).toBe(false) }, 60_000) }) describe('generate() — the scoped variant (in-process)', () => { it('--modules matching none of the registries on disk → nothing emitted, the warning lists what IS there', () => { write( 'src/extensions/myapp-rhRegistry.ts', `import { PageRegistry, lazyWithRetry } from '@atlashub/smartstack';\nconst JourFeriesListPage = lazyWithRetry(() => import('@/pages/myapp/rh/jours-feries/JourFeriesListPage'));\nPageRegistry.register('myapp.rh.jours-feries', JourFeriesListPage);\n`, ) write('src/pages/myapp/rh/jours-feries/JourFeriesListPage.tsx', 'export default () => null\n') const res = generate({ projectPath: web, modules: ['crm-clients'], exclude: [] }) expect(res.errors).toEqual([]) expect(res.files).toEqual([]) expect(res.parsed).toEqual([]) const joined = res.warnings.join('\n') expect(joined).toContain('Requested module registry not found on disk: crm-clientsRegistry.ts') expect(joined).toContain('--modules selected 0 of the 1 per-module registry') expect(joined).toContain('myapp-rhRegistry.ts') expect(joined).toContain('nothing aggregated') }) }) describe('index.ts — one real registry from scaffold-routes generate() (SILENT)', () => { it('aggregates it: success:true, no warning, the aggregate + i18n sibling written', () => { // The REAL emitter, parsed the way its index.ts parses it (Zod defaults applied). const spec = ScaffoldRoutesInputSchema.parse({ module: 'rh', appCode: 'myapp', projectPath: web, entities: [{ name: 'JourFerie', section: 'jours-feries', views: ['list', 'detail', 'create', 'edit'] }], }) const emitted = scaffoldRoutes(spec) for (const f of emitted) write(f.path, f.content) const registry = emitted.find((f) => /Registry\.ts$/.test(f.path))! expect(registry.path).toBe('src/extensions/myapp-rhRegistry.ts') // Every page the registry lazily imports exists on disk — read from the REAL output. for (const m of registry.content.matchAll(/import\(\s*['"]@\/([^'"]+)['"]\s*\)/g)) { write(`src/${m[1]}.tsx`, 'export default () => null\n') } const expectedKeys = [...registry.content.matchAll(PAGE_REGISTER_RE)].length expect(expectedKeys).toBeGreaterThan(0) // The module's locale bundles (scaffold-component's shape) — the i18n sibling is emitted. write('src/i18n/locales/fr/rh.json', '{}') write('src/i18n/locales/en/rh.json', '{}') const { code, envelope } = run(['--project-path', web]) expect(code).toBe(0) expect(envelope.success).toBe(true) expect(envelope.errors).toEqual([]) expect(envelope.warnings).toEqual([]) expect(envelope.data?.modulesAggregated).toBe(1) expect(envelope.data?.registeredKeys).toBe(expectedKeys) expect(envelope.data?.modules).toEqual([{ module: 'myapp-rh', keys: expectedKeys }]) expect(envelope.nextSteps.join('\n')).toContain('src/main.tsx') expect(envelope.filesCreated).toHaveLength(2) const aggregate = fs.readFileSync(path.join(web, AGGREGATE), 'utf-8') expect(aggregate).toContain(`import './myapp-rhRegistry';`) expect(aggregate).toContain(`import './moduleResources.generated';`) expect(fs.existsSync(path.join(web, 'src/extensions/moduleResources.generated.ts'))).toBe(true) }, 60_000) })