/** * registry-index — the tolerant PageRegistry parser + layout detector. * * This lib is what lets the /pwa chain SEE a legacy MCP-era monolithic * registry (componentRegistry.generated.ts registering every page inline) * instead of silently scanning zero registrations and destroying it. The * fixtures below pin the three layouts (legacy-monolith / per-module / mixed) * and every registration form the balanced-argument scanner must read. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { buildRegistryIndex, detectRegistryLayout, parseRegistrySource, resolveRegistryImport, fileKindOf, AGGREGATE_BASENAME, } from '../registry-index.js' let root: string beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'regidx-')) }) afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) }) function write(rel: string, content: string): void { const abs = path.join(root, rel) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, content) } /** A PickEBike-shaped monolith: inline lazy, one 3-arg meta, a lazyWithRetry const. */ const MONOLITH = `// Auto-generated by SmartStack MCP import { lazy } from 'react'; import { PageRegistry, lazyWithRetry } from '@atlashub/smartstack'; PageRegistry.register('fleet.bikes', lazy(() => import('@/pages/fleet/bikes/FleetBikeListPage'))); PageRegistry.register('fleet.bikes.detail', lazy(() => import('@/pages/fleet/bikes/FleetBikeDetailPage')), { mobile: { support: 'adapted', offlineCapable: true }, }); const StationsPage = lazyWithRetry(() => import('@/pages/fleet/stations/StationsPage').then((m) => ({ default: m.StationsPage }))); PageRegistry.register('fleet.stations', StationsPage); ` /** The canonical per-module form scaffold-routes emits. */ const PER_MODULE = `import { PageRegistry, lazyWithRetry } from '@atlashub/smartstack'; const BikesListPage = lazyWithRetry(() => import('@/pages/fleet/bikes/BikesListPage').then((m) => ({ default: m.default })), ); PageRegistry.register('fleet.bikes', BikesListPage, { mobile: { support: 'adapted', offlineCapable: true, }, }); ` const CLEAN_AGGREGATE = `// componentRegistry.generated.ts — Auto-aggregated module registrations import './fleet-bikesRegistry'; import './moduleResources.generated'; ` describe('parseRegistrySource', () => { it('reads the inline-lazy monolith form, meta verbatim included', () => { const regs = parseRegistrySource(MONOLITH, { file: 'x.ts', registryAbsPath: null, webRoot: root }) expect(regs.map((r) => r.key)).toEqual(['fleet.bikes', 'fleet.bikes.detail', 'fleet.stations']) expect(regs[0]!.form).toBe('inline-lazy') expect(regs[0]!.importPath).toBe('@/pages/fleet/bikes/FleetBikeListPage') expect(regs[0]!.metaLiteral).toBeNull() expect(regs[1]!.metaLiteral).toContain("support: 'adapted'") expect(regs[1]!.metaLiteral!.startsWith('{')).toBe(true) expect(regs[1]!.metaLiteral!.endsWith('}')).toBe(true) expect(regs[2]!.form).toBe('const-ref') expect(regs[2]!.importPath).toBe('@/pages/fleet/stations/StationsPage') expect(regs[0]!.line).toBe(5) expect(regs[0]!.statement).toContain("PageRegistry.register('fleet.bikes'") }) it('reads the canonical const-ref form with the .then continuation and multi-line meta', () => { const regs = parseRegistrySource(PER_MODULE, { file: 'x.ts', registryAbsPath: null, webRoot: root }) expect(regs).toHaveLength(1) expect(regs[0]!.form).toBe('const-ref') expect(regs[0]!.importPath).toBe('@/pages/fleet/bikes/BikesListPage') expect(regs[0]!.metaLiteral).toContain('offlineCapable: true') }) it('classifies static default and named imports as const-ref-static', () => { const src = `import HomePage from '@/pages/HomePage'; import { AboutPage } from '@/pages/AboutPage'; PageRegistry.register('app.home', HomePage); PageRegistry.register('app.about', AboutPage); ` const regs = parseRegistrySource(src, { file: 'x.ts', registryAbsPath: null, webRoot: root }) expect(regs.map((r) => r.form)).toEqual(['const-ref-static', 'const-ref-static']) expect(regs[0]!.importPath).toBe('@/pages/HomePage') expect(regs[1]!.importPath).toBe('@/pages/AboutPage') }) it('classifies an unknown identifier as unresolved-ref instead of dropping it', () => { const src = `PageRegistry.register('app.mystery', MysteryPage);\n` const regs = parseRegistrySource(src, { file: 'x.ts', registryAbsPath: null, webRoot: root }) expect(regs).toHaveLength(1) expect(regs[0]!.form).toBe('unresolved-ref') expect(regs[0]!.importPath).toBeNull() }) it('skips a non-literal key (caught later as unmigratable source, never silently lost)', () => { const src = `const k = 'app.x';\nPageRegistry.register(k, lazy(() => import('@/pages/X')));\nPageRegistry.register('app.y', lazy(() => import('@/pages/Y')));\n` const regs = parseRegistrySource(src, { file: 'x.ts', registryAbsPath: null, webRoot: root }) expect(regs.map((r) => r.key)).toEqual(['app.y']) }) it('is not confused by comments or string contents inside the call', () => { const src = `PageRegistry.register( 'app.tricky', // key with ')' in a comment lazy(() => import('@/pages/Tricky')), /* meta below } */ { mobile: { support: 'full', label: "has ) and } inside" } }, ); ` const regs = parseRegistrySource(src, { file: 'x.ts', registryAbsPath: null, webRoot: root }) expect(regs).toHaveLength(1) expect(regs[0]!.key).toBe('app.tricky') expect(regs[0]!.metaLiteral).toContain('has ) and } inside') }) }) describe('resolveRegistryImport', () => { it('resolves @/ against src with the .tsx/.ts/index fallbacks', () => { write('src/pages/A.tsx', 'export {}') write('src/pages/B/index.ts', 'export {}') expect(resolveRegistryImport('@/pages/A', root)).toBe(path.join(root, 'src', 'pages', 'A.tsx')) expect(resolveRegistryImport('@/pages/B', root)).toBe(path.join(root, 'src', 'pages', 'B', 'index.ts')) }) it('resolves a relative path against the registry file directory', () => { write('src/extensions/helpers/Overlay.tsx', 'export {}') const registryAbs = path.join(root, 'src', 'extensions', 'xRegistry.ts') expect(resolveRegistryImport('./helpers/Overlay', root, registryAbs)).toBe( path.join(root, 'src', 'extensions', 'helpers', 'Overlay.tsx'), ) }) it('returns null for a phantom target', () => { expect(resolveRegistryImport('@/pages/DoesNotExist', root)).toBeNull() }) }) describe('buildRegistryIndex / detectRegistryLayout', () => { it('legacy-monolith: aggregate registers its own pages, zero per-module registry', () => { write(`src/extensions/${AGGREGATE_BASENAME}`, MONOLITH) write('src/pages/fleet/bikes/FleetBikeListPage.tsx', 'export default () => null') const index = buildRegistryIndex(root) expect(index.layout).toBe('legacy-monolith') expect(index.aggregate).not.toBeNull() expect(index.aggregate!.registrations).toHaveLength(3) expect(index.byKey.get('fleet.bikes')!.registration.resolvedAbsPath).toBe( path.join(root, 'src', 'pages', 'fleet', 'bikes', 'FleetBikeListPage.tsx'), ) }) it('per-module: clean aggregate (side-effect imports only) + registering module files', () => { write(`src/extensions/${AGGREGATE_BASENAME}`, CLEAN_AGGREGATE) write('src/extensions/fleet-bikesRegistry.ts', PER_MODULE) const index = buildRegistryIndex(root) expect(index.layout).toBe('per-module') // the aggregate is still indexed (its emptiness IS the signal) expect(index.aggregate).not.toBeNull() expect(index.aggregate!.registrations).toHaveLength(0) }) it('mixed: aggregate with own registers WHILE per-module files exist — collisions surfaced', () => { write(`src/extensions/${AGGREGATE_BASENAME}`, MONOLITH) write('src/extensions/fleet-bikesRegistry.ts', PER_MODULE) const index = buildRegistryIndex(root) expect(index.layout).toBe('mixed') expect(index.collisions.map((c) => c.key)).toEqual(['fleet.bikes']) expect(index.collisions[0]!.files.sort()).toEqual([ `src/extensions/${AGGREGATE_BASENAME}`, 'src/extensions/fleet-bikesRegistry.ts', ]) }) it('none: no registry registers anything (fresh app)', () => { write(`src/extensions/${AGGREGATE_BASENAME}`, CLEAN_AGGREGATE) expect(detectRegistryLayout(root)).toBe('none') write('src/extensions/outbox.generated.ts', 'export {}') expect(detectRegistryLayout(root)).toBe('none') }) it('none: src/extensions absent entirely', () => { expect(detectRegistryLayout(root)).toBe('none') }) it("'other' files count only when they register", () => { write('src/extensions/vitrine.generated.ts', "export const x = 1 // no register") write( 'src/extensions/custom.generated.ts', "import { PageRegistry } from '@atlashub/smartstack';\nPageRegistry.register('app.custom', lazy(() => import('@/pages/Custom')));\n", ) const index = buildRegistryIndex(root) expect(index.entries.map((e) => e.file)).toEqual(['src/extensions/custom.generated.ts']) expect(index.entries[0]!.kind).toBe('other') // an 'other' file registering does not flip the layout to per-module expect(index.layout).toBe('none') }) }) describe('fileKindOf', () => { it('classifies the three kinds', () => { expect(fileKindOf(AGGREGATE_BASENAME)).toBe('aggregate') expect(fileKindOf('fleet-bikesRegistry.ts')).toBe('per-module') expect(fileKindOf('outbox.generated.ts')).toBe('other') expect(fileKindOf('moduleResources.generated.ts')).toBe('other') }) })