/** * scaffold-vitrine — pure-layer tests (generate() output + helpers). * * Everything asserted here is deterministic: we drive the real `generate()` and * inspect the emitted files, so the page<->i18n key contract and the wiring are * checked against the actual output a developer would get. */ import { describe, it, expect } from 'vitest'; import { generate, injectVitrineExtensions, VITRINE_IMPORT_LINE, buildNavLinks, countSections, } from '../generate.js'; import { cmpSemver } from '../validate.js'; import { ScaffoldVitrineInputSchema, isReservedPath } from '../types.js'; import type { GeneratedFile } from '../types.js'; const LAYOUT = { webDir: 'web/demo-web' }; const SRC = 'web/demo-web/src'; function parse(raw: unknown) { return ScaffoldVitrineInputSchema.parse(raw); } function gen(raw: unknown): GeneratedFile[] { return generate(parse(raw), LAYOUT); } function byPath(files: GeneratedFile[], suffix: string): GeneratedFile { const f = files.find((x) => x.path === `${SRC}/${suffix}` || x.path.endsWith(suffix)); if (!f) throw new Error(`file not found: ${suffix}\nhave:\n${files.map((x) => x.path).join('\n')}`); return f; } const HOME_SPEC = { projectPath: '/tmp/app', appCode: 'demo', home: { sections: [ { type: 'hero', badge: 'Nouveau', headline: 'Bienvenue', highlight: 'chez Acme', subhead: 'La plateforme métier.', primaryCta: { label: 'Commencer', href: '/register' }, secondaryCta: { label: 'En savoir plus', href: '#features' }, bullets: ['Rapide', 'Sécurisé'], }, { type: 'features', title: 'Fonctionnalités', items: [ { icon: 'Users', title: 'Équipes', description: 'Gérez vos équipes.' }, { icon: 'Zap', title: 'Vitesse', description: 'Ultra rapide.' }, ], }, ], }, }; describe('home override', () => { it('emits HomePage.tsx that composes the sections and reads the vitrine namespace', () => { const files = gen(HOME_SPEC); const home = byPath(files, 'vitrine/pages/HomePage.tsx'); expect(home.strategy).toBe('skip-if-exists'); expect(home.content).toContain('export function HomePage(_props?: PageProps): ReactElement'); expect(home.content).toContain("useTranslation('vitrine')"); expect(home.content).toContain(' { const wiring = byPath(gen(HOME_SPEC), 'extensions/vitrine.generated.ts'); expect(wiring.strategy).toBe('overwrite'); expect(wiring.content).toContain("import { HomePage } from '../vitrine/pages/HomePage';"); expect(wiring.content).toContain('export const vitrineExtensions: ExtensionConfig = { pages: { [PAGE_KEYS.HOME]: HomePage } };'); expect(wiring.content).toContain("addClientResources('fr', { vitrine: frVitrine });"); expect(wiring.content).toContain("addClientResources('en', { vitrine: enVitrine });"); }); it('emits only the section components actually used', () => { const files = gen(HOME_SPEC); expect(() => byPath(files, 'vitrine/sections/Hero.tsx')).not.toThrow(); expect(() => byPath(files, 'vitrine/sections/FeatureGrid.tsx')).not.toThrow(); // no split/cta/faq/logos in this spec expect(files.find((f) => f.path.endsWith('SplitFeature.tsx'))).toBeUndefined(); expect(files.find((f) => f.path.endsWith('CtaBanner.tsx'))).toBeUndefined(); // no white-label chrome expect(files.find((f) => f.path.endsWith('MarketingHeader.tsx'))).toBeUndefined(); }); }); describe('i18n contract (page keys ↔ JSON)', () => { it('JSON holds the copy under indexed section keys; the page references the same keys', () => { const files = gen(HOME_SPEC); const fr = byPath(files, 'vitrine/locales/fr/vitrine.json'); expect(fr.strategy).toBe('deep-merge-json'); const json = JSON.parse(fr.content) as Record; expect(json.home.s0.headline).toBe('Bienvenue'); expect(json.home.s0.badge).toBe('Nouveau'); expect(json.home.s0.bullets['0']).toBe('Rapide'); expect(json.home.s0.primaryCta).toBe('Commencer'); // label only; href is structural expect(json.home.s1.items['0'].title).toBe('Équipes'); const home = byPath(files, 'vitrine/pages/HomePage.tsx'); expect(home.content).toContain('t("home.s0.headline")'); expect(home.content).toContain('t("home.s0.bullets.0")'); expect(home.content).toContain('t("home.s1.items.0.title")'); // hrefs stay in the page, not i18n expect(home.content).toContain('href: "/register"'); }); it('seeds every requested language with the same copy', () => { const files = gen({ ...HOME_SPEC, languages: ['fr', 'en'] }); const fr = byPath(files, 'vitrine/locales/fr/vitrine.json'); const en = byPath(files, 'vitrine/locales/en/vitrine.json'); expect(fr.content).toBe(en.content); }); }); describe('icons', () => { it('keeps allowlisted icons and falls back to Sparkles for unknown ones', () => { const files = gen({ ...HOME_SPEC, home: { sections: [ { type: 'features', items: [ { icon: 'Users', title: 'A', description: 'a' }, { icon: 'TotallyNotAnIcon', title: 'B', description: 'b' }, ], }, ], }, }); const home = byPath(files, 'vitrine/pages/HomePage.tsx'); expect(home.content).toContain('Users'); expect(home.content).toContain('Sparkles'); expect(home.content).toContain("from 'lucide-react'"); expect(home.content).not.toContain('TotallyNotAnIcon'); }); it('does not import lucide on a page without a features section', () => { const files = gen({ projectPath: '/tmp/app', appCode: 'demo', home: { sections: [{ type: 'cta', headline: 'Go', primaryCta: { label: 'X', href: '/register' } }] }, }); const home = byPath(files, 'vitrine/pages/HomePage.tsx'); expect(home.content).not.toContain("from 'lucide-react'"); }); }); describe('public pages', () => { const SPEC = { projectPath: '/tmp/app', appCode: 'demo', pages: [ { name: 'About', path: '/about', layout: 'public', sections: [{ type: 'split', title: 'À propos', body: 'Notre histoire.' }], }, ], }; it('registers a public route with lazyWithRetry and the correct path/layout', () => { const wiring = byPath(gen(SPEC), 'extensions/vitrine.generated.ts'); expect(wiring.content).toContain('const AboutPage = lazyWithRetry(() =>'); expect(wiring.content).toContain("import('../vitrine/pages/AboutPage')"); expect(wiring.content).toContain('PublicRouteRegistry.register({ path: "/about", component: AboutPage, layout: "public" });'); // no home → empty pages map expect(wiring.content).toContain('export const vitrineExtensions: ExtensionConfig = { pages: {} };'); }); it('a public (non-white-label) page does not pull in the marketing chrome', () => { const files = gen(SPEC); const about = byPath(files, 'vitrine/pages/AboutPage.tsx'); expect(about.content).not.toContain('MarketingHeader'); expect(about.content).toContain('
'); expect(files.find((f) => f.path.endsWith('MarketingHeader.tsx'))).toBeUndefined(); }); }); describe('white-label pages (layout: none)', () => { const SPEC = { projectPath: '/tmp/app', appCode: 'demo', branding: { appName: 'Acme', tagline: 'On vous simplifie la vie' }, pages: [ { name: 'Landing', path: '/landing', layout: 'none', sections: [{ type: 'hero', headline: 'Acme' }], }, ], }; it('wraps the page in MarketingHeader/MarketingFooter and emits the chrome components', () => { const files = gen(SPEC); const page = byPath(files, 'vitrine/pages/LandingPage.tsx'); expect(page.content).toContain("import { MarketingHeader } from '../sections/MarketingHeader';"); expect(page.content).toContain("import { MarketingFooter } from '../sections/MarketingFooter';"); expect(page.content).toContain(' byPath(files, 'vitrine/sections/MarketingHeader.tsx')).not.toThrow(); expect(() => byPath(files, 'vitrine/sections/MarketingFooter.tsx')).not.toThrow(); }); it('registers the route with layout "none"', () => { const wiring = byPath(gen(SPEC), 'extensions/vitrine.generated.ts'); expect(wiring.content).toContain('layout: "none"'); }); }); describe('schema validation', () => { it('rejects when neither home nor pages is provided', () => { const r = ScaffoldVitrineInputSchema.safeParse({ projectPath: '/x', appCode: 'd' }); expect(r.success).toBe(false); }); it('rejects duplicate page names', () => { const r = ScaffoldVitrineInputSchema.safeParse({ projectPath: '/x', appCode: 'd', pages: [ { name: 'About', path: '/about', sections: [{ type: 'hero', headline: 'a' }] }, { name: 'About', path: '/about-2', sections: [{ type: 'hero', headline: 'b' }] }, ], }); expect(r.success).toBe(false); }); it('rejects a non-PascalCase page name and a non-absolute path', () => { expect( ScaffoldVitrineInputSchema.safeParse({ projectPath: '/x', appCode: 'd', pages: [{ name: 'about', path: '/about', sections: [{ type: 'hero', headline: 'a' }] }], }).success, ).toBe(false); expect( ScaffoldVitrineInputSchema.safeParse({ projectPath: '/x', appCode: 'd', pages: [{ name: 'About', path: 'about', sections: [{ type: 'hero', headline: 'a' }] }], }).success, ).toBe(false); }); it('defaults page layout to public and languages to fr+en', () => { const spec = parse({ projectPath: '/x', appCode: 'd', pages: [{ name: 'About', path: '/about', sections: [{ type: 'hero', headline: 'a' }] }], }); expect(spec.pages?.[0].layout).toBe('public'); expect(spec.languages).toEqual(['fr', 'en']); }); }); describe('isReservedPath (mirrors PublicRouteRegistry)', () => { it('flags reserved exact paths and prefixes', () => { expect(isReservedPath('/')).toBe(true); expect(isReservedPath('/login')).toBe(true); expect(isReservedPath('/register')).toBe(true); expect(isReservedPath('/docs')).toBe(true); expect(isReservedPath('/docs/getting-started')).toBe(true); expect(isReservedPath('/t')).toBe(true); expect(isReservedPath('/t/acme')).toBe(true); }); it('allows ordinary marketing paths', () => { expect(isReservedPath('/about')).toBe(false); expect(isReservedPath('/pricing')).toBe(false); expect(isReservedPath('/contact')).toBe(false); // a path that merely starts with the same letters as a reserved one is fine expect(isReservedPath('/documents')).toBe(false); expect(isReservedPath('/team')).toBe(false); }); }); describe('main.tsx wiring (idempotent)', () => { const MAIN = `import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { SmartStackProvider } from '@atlashub/smartstack'; import './extensions/componentRegistry.generated'; import App from './App'; import './index.css'; import './i18n'; createRoot(document.getElementById('root')!).render( , ); `; it('adds the import after componentRegistry and spreads vitrineExtensions', () => { const { content, status } = injectVitrineExtensions(MAIN); expect(status).toBe('applied'); expect(content).toContain(VITRINE_IMPORT_LINE); expect(content.indexOf("componentRegistry.generated")).toBeLessThan(content.indexOf('vitrine.generated')); expect(content).toContain('extensions: { ...vitrineExtensions }'); expect(content).not.toContain('extensions: {}'); }); it('is idempotent (second pass is unchanged, no duplicate import)', () => { const once = injectVitrineExtensions(MAIN).content; const twice = injectVitrineExtensions(once); expect(twice.status).toBe('unchanged'); expect(twice.content).toBe(once); expect(once.match(/vitrineExtensions } from/g)?.length).toBe(1); }); it('bails atomically when the empty extensions object is absent (customized main.tsx)', () => { const customized = MAIN.replace('extensions: {}', 'extensions: { pages: { home: MyHome } }'); const { content, status } = injectVitrineExtensions(customized); expect(status).toBe('not-found'); expect(content).toBe(customized); // unchanged — no dangling import }); it('skips a main.tsx marked // @customised', () => { const optedOut = `// @customised\n${MAIN}`; expect(injectVitrineExtensions(optedOut).status).toBe('skipped-customised'); }); }); describe('determinism + helpers', () => { it('generate() is deterministic (same spec → identical output)', () => { const a = gen(HOME_SPEC); const b = gen(HOME_SPEC); expect(a.map((f) => `${f.path}\n${f.content}`)).toEqual(b.map((f) => `${f.path}\n${f.content}`)); }); it('buildNavLinks lists home plus every page', () => { const spec = parse({ projectPath: '/x', appCode: 'd', pages: [ { name: 'About', path: '/about', title: 'À propos', sections: [{ type: 'hero', headline: 'a' }] }, { name: 'Pricing', path: '/pricing', sections: [{ type: 'hero', headline: 'b' }] }, ], }); expect(buildNavLinks(spec)).toEqual([ { label: 'Home', href: '/' }, { label: 'À propos', href: '/about' }, { label: 'Pricing', href: '/pricing' }, ]); }); it('countSections sums home + pages', () => { expect(countSections(parse(HOME_SPEC))).toBe(2); }); it('cmpSemver orders versions correctly', () => { expect(cmpSemver('3.54.0', '3.55.0')).toBe(-1); expect(cmpSemver('3.55.0', '3.55.0')).toBe(0); expect(cmpSemver('3.56.0', '3.55.0')).toBe(1); expect(cmpSemver('3.55.1', '3.55.0')).toBe(1); expect(cmpSemver('3.55.0-beta.1', '3.55.0')).toBe(0); // prerelease stripped }); });