/** * aggregate-component-registry — i18n module-resource emission. * * Guards the regression where scaffolded module registries never registered * their translations through the SDK-surviving `addClientResources` channel, so * pages rendered raw i18n keys. The aggregator now emits a deterministic * `moduleResources.generated.ts` (the former hand-owned file) and wires its * import at the END of `componentRegistry.generated.ts` (which main.tsx imports * AFTER the SDK init). * * The bundles are discovered by SCANNING `src/i18n/locales//*.json` * (basename = namespace) — NOT derived from registry filenames. Registries are * app-scoped (`-Registry.ts`) while scaffold-component writes bare * `.json`; name-derived lookup silently matched nothing (raw keys on * every business page). The scan also registers `common.json` (ui-primitives / * dashboard keys the SDK core `common` namespace does not provide). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { generate, buildModuleResourcesFile, parseModuleResourcesConsistency, hasBusinessPages } from '../generate.js' let root: string function write(rel: string, content: string): void { const abs = path.join(root, rel) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, content, 'utf-8') } function mkdir(rel: string): void { fs.mkdirSync(path.join(root, rel), { recursive: true }) } /** A minimal per-module registry whose lazy import resolves on disk. */ function registry(module: string, key: string, pageVar: string): string { return `import { PageRegistry, lazyWithRetry } from '@atlashub/smartstack'; const ${pageVar} = lazyWithRetry(() => import('@/pages/${pageVar}')); PageRegistry.register('${key}', ${pageVar}); ` } beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'agg-i18n-')) }) afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) }) describe('buildModuleResourcesFile', () => { it('registers every locale bundle on disk through addClientResources', () => { write('src/i18n/locales/fr/taches.json', '{}') write('src/i18n/locales/en/taches.json', '{}') write('src/i18n/locales/fr/employes.json', '{}') write('src/i18n/locales/en/employes.json', '{}') const out = buildModuleResourcesFile(root)! expect(out).toContain(`import { addClientResources } from '@atlashub/smartstack';`) expect(out).toContain(`import frTaches from '../i18n/locales/fr/taches.json';`) expect(out).toContain(`import enEmployes from '../i18n/locales/en/employes.json';`) expect(out).toContain(`addClientResources('fr', {`) expect(out).toContain(`addClientResources('en', {`) expect(out).toContain(`taches: frTaches,`) expect(out).toContain(`employes: enEmployes,`) }) it('quotes dashed namespace keys and PascalCases the import ident', () => { write('src/i18n/locales/fr/mes-taches.json', '{}') const out = buildModuleResourcesFile(root)! expect(out).toContain(`import frMesTaches from '../i18n/locales/fr/mes-taches.json';`) expect(out).toContain(`'mes-taches': frMesTaches,`) }) it('registers only what exists on disk (no phantom namespaces)', () => { write('src/i18n/locales/fr/taches.json', '{}') const out = buildModuleResourcesFile(root)! expect(out).toContain('frTaches') expect(out).not.toContain('ghost') }) it('is decoupled from registry naming — a bare .json is found even when registries are app-scoped', () => { // The exact historical bug: registries renamed to -Registry.ts // while scaffold-component kept writing bare .json — the old // name-derived lookup matched nothing and business pages rendered raw keys. write('src/i18n/locales/fr/clients.json', '{}') const out = buildModuleResourcesFile(root)! expect(out).toContain(`import frClients from '../i18n/locales/fr/clients.json';`) expect(out).toContain(`clients: frClients,`) }) it('registers common.json (ui-primitives / dashboard keys the SDK does not ship)', () => { write('src/i18n/locales/fr/common.json', '{}') write('src/i18n/locales/fr/taches.json', '{}') const out = buildModuleResourcesFile(root)! expect(out).toContain(`import frCommon from '../i18n/locales/fr/common.json';`) expect(out).toContain(`common: frCommon,`) expect(out).toContain(`taches: frTaches,`) expect(out).toMatch(/^\/\/ Modules: 2 /m) }) it('skips vitrine/login bundles — their own generated files register them', () => { write('src/i18n/locales/fr/vitrine.json', '{}') write('src/i18n/locales/fr/login.json', '{}') expect(buildModuleResourcesFile(root)).toBeNull() write('src/i18n/locales/fr/taches.json', '{}') const out = buildModuleResourcesFile(root)! // (the static header comment mentions vitrine.generated.ts — assert on the // import/registration lines, not the whole file) expect(out).not.toContain('vitrine.json') expect(out).not.toContain('login.json') expect(out).not.toContain('frVitrine') expect(out).not.toContain('frLogin') expect(out).toContain('taches: frTaches,') }) it('returns null when there is no locales directory', () => { expect(buildModuleResourcesFile(root)).toBeNull() }) it('returns null when the locales directory is present but holds no bundle', () => { mkdir('src/i18n/locales/fr') expect(buildModuleResourcesFile(root)).toBeNull() }) }) describe('hasBusinessPages', () => { it('detects nested business pages and ignores Home/hub pages', () => { expect(hasBusinessPages(root)).toBe(false) write('src/pages/crm/clients/ClientsSectionHomePage.tsx', 'export default null;') expect(hasBusinessPages(root)).toBe(false) write('src/pages/crm/clients/ClientListPage.tsx', 'export default null;') expect(hasBusinessPages(root)).toBe(true) }) }) describe('generate — wires moduleResources into the component registry', () => { it('emits moduleResources.generated.ts and imports it at the end of the aggregator', () => { write('src/pages/TachesList.tsx', 'export const TachesList = () => null;') write('src/extensions/tachesRegistry.ts', registry('taches', 'rh.taches.list', 'TachesList')) write('src/i18n/locales/fr/taches.json', '{}') write('src/i18n/locales/en/taches.json', '{}') const { files, unresolved, collisions } = generate({ projectPath: root, exclude: [] }) expect(unresolved).toHaveLength(0) expect(collisions).toHaveLength(0) const registryFile = files.find((f) => f.path.endsWith('componentRegistry.generated.ts'))! const i18nFile = files.find((f) => f.path.endsWith('moduleResources.generated.ts')) expect(i18nFile).toBeDefined() expect(registryFile.content).toContain(`import './moduleResources.generated';`) // The i18n import must come AFTER the per-module registries (so it runs after // the SDK init, which main.tsx triggers before importing this aggregator). expect(registryFile.content.indexOf(`import './tachesRegistry';`)) .toBeLessThan(registryFile.content.indexOf(`import './moduleResources.generated';`)) }) it('emits moduleResources for an APP-SCOPED registry with a bare module bundle (the raw-keys bug)', () => { write('src/pages/ClientListPage.tsx', 'export const ClientListPage = () => null;') write('src/extensions/crm-clientsRegistry.ts', registry('crm-clients', 'crm.clients.list', 'ClientListPage')) write('src/i18n/locales/fr/clients.json', '{}') const { files, errors, warnings } = generate({ projectPath: root, exclude: [] }) expect(errors).toHaveLength(0) expect(warnings.some((w) => w.includes('found NO locale bundle'))).toBe(false) const i18nFile = files.find((f) => f.path.endsWith('moduleResources.generated.ts'))! expect(i18nFile).toBeDefined() expect(i18nFile.content).toContain('clients: frClients,') const registryFile = files.find((f) => f.path.endsWith('componentRegistry.generated.ts'))! expect(registryFile.content).toContain(`import './crm-clientsRegistry';`) expect(registryFile.content).toContain(`import './moduleResources.generated';`) }) it('emits NO moduleResources import when the app has no module locale files', () => { write('src/pages/TachesList.tsx', 'export const TachesList = () => null;') write('src/extensions/tachesRegistry.ts', registry('taches', 'rh.taches.list', 'TachesList')) const { files } = generate({ projectPath: root, exclude: [] }) const registryFile = files.find((f) => f.path.endsWith('componentRegistry.generated.ts'))! expect(registryFile.content).not.toContain('moduleResources.generated') expect(files.find((f) => f.path.endsWith('moduleResources.generated.ts'))).toBeUndefined() }) }) describe('generate — i18n regression guard (②) and ordering guard (④)', () => { it('② hard-errors when a prior moduleResources.generated.ts exists but this run resolves no locale bundle', () => { write('src/pages/TachesList.tsx', 'export const TachesList = () => null;') write('src/extensions/tachesRegistry.ts', registry('taches', 'rh.taches.list', 'TachesList')) // Stale sibling from a prior (good) run — but NO locale JSON exists this run. write( 'src/extensions/moduleResources.generated.ts', `import { addClientResources } from '@atlashub/smartstack';\naddClientResources('fr', { taches: {} });\n`, ) const { files, errors } = generate({ projectPath: root, exclude: [] }) expect(errors).toHaveLength(1) expect(errors[0]).toContain('i18n regression guard') // Nothing emitted — the caller (index.ts) exits 1 before writing. expect(files).toHaveLength(0) }) it('④ warns (non-fatal) when no bundle matched and no business page exists', () => { write('src/pages/TachesList.tsx', 'export const TachesList = () => null;') write('src/extensions/tachesRegistry.ts', registry('taches', 'rh.taches.list', 'TachesList')) mkdir('src/i18n/locales/fr') // locales dir exists, but holds no bundle const { files, errors, warnings } = generate({ projectPath: root, exclude: [] }) expect(errors).toHaveLength(0) expect(warnings.some((w) => w.includes('found NO locale bundle'))).toBe(true) // componentRegistry still emitted, without the i18n import; no sibling emitted. const registryFile = files.find((f) => f.path.endsWith('componentRegistry.generated.ts'))! expect(registryFile).toBeDefined() expect(registryFile.content).not.toContain('moduleResources.generated') expect(files.find((f) => f.path.endsWith('moduleResources.generated.ts'))).toBeUndefined() }) it('④ hard-errors when no bundle matched but BUSINESS pages exist (they WILL render raw keys)', () => { write('src/pages/crm/clients/ClientListPage.tsx', 'export const ClientListPage = () => null;') write('src/pages/ClientListPage.tsx', 'export const ClientListPage = () => null;') write('src/extensions/crm-clientsRegistry.ts', registry('crm-clients', 'crm.clients.list', 'ClientListPage')) mkdir('src/i18n/locales/fr') // locales dir exists, but holds no bundle const { files, errors } = generate({ projectPath: root, exclude: [] }) expect(errors.some((e) => e.includes('i18n ordering guard'))).toBe(true) expect(files).toHaveLength(0) }) it('neither warns nor errors for a genuinely i18n-less app (no locales dir, no sibling)', () => { write('src/pages/TachesList.tsx', 'export const TachesList = () => null;') write('src/extensions/tachesRegistry.ts', registry('taches', 'rh.taches.list', 'TachesList')) const { errors, warnings } = generate({ projectPath: root, exclude: [] }) expect(errors).toHaveLength(0) expect(warnings.some((w) => w.includes('found NO locale bundle'))).toBe(false) }) }) describe('parseModuleResourcesConsistency — ⑤ canary core', () => { it('returns null when the "Modules:" header is absent', () => { expect(parseModuleResourcesConsistency(`addClientResources('fr', { taches: x });`)).toBeNull() }) it('agrees (declared === namespaces.length) for a clean generated file', () => { write('src/i18n/locales/fr/taches.json', '{}') write('src/i18n/locales/en/taches.json', '{}') write('src/i18n/locales/fr/employes.json', '{}') const out = buildModuleResourcesFile(root)! const parsed = parseModuleResourcesConsistency(out)! expect(parsed.declared).toBe(2) expect(parsed.namespaces).toEqual(['employes', 'taches']) }) it('stays consistent when common.json is registered alongside module bundles', () => { write('src/i18n/locales/fr/common.json', '{}') write('src/i18n/locales/fr/taches.json', '{}') const out = buildModuleResourcesFile(root)! const parsed = parseModuleResourcesConsistency(out)! expect(parsed.declared).toBe(2) expect(parsed.namespaces).toEqual(['common', 'taches']) }) it('detects a torn header/body mismatch (Modules: 6 over a 1-namespace body)', () => { const torn = `// Modules: 6 · Locales: fr\nimport { addClientResources } from '@atlashub/smartstack';\naddClientResources('fr', {\n taches: frTaches,\n});\n` const parsed = parseModuleResourcesConsistency(torn)! expect(parsed.declared).toBe(6) expect(parsed.namespaces).toEqual(['taches']) // 1 ≠ 6 → the canary caller fails loud }) it('parses quoted (dashed) namespace keys', () => { const src = `// Modules: 1 · Locales: fr\naddClientResources('fr', {\n 'mes-taches': frMesTaches,\n});\n` const parsed = parseModuleResourcesConsistency(src)! expect(parsed.namespaces).toEqual(['mes-taches']) }) })