import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { execute } from '../execute.js' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' /** * Fixture project shape mirroring a SmartStack-generated web app: * / * tsconfig.json * src/ * components/ui/PageTemplate.tsx * features/crm/contact/ * hooks/useContact.ts * services/contactService.ts * types/index.ts * i18n/locales/{fr,en,it,de}/contact.json * pages/myapp/crm/contacts/ContactListPage.tsx */ let projectPath: string const TSCONFIG = JSON.stringify({ compilerOptions: { paths: { '@/*': ['src/*'] }, }, }) const PAGE_TEMPLATE = `export function PageTemplate({ children }: { children: any }) { return children }\n` const HOOK_FILE = `export function useContact() { return { data: null } }\nexport function useDeleteContact() { return { mutateAsync: async () => {} } }\n` const SERVICE_FILE = `export const contactService = {}\n` const TYPES_FILE = `export interface ContactDetailDto { id: string }\n` const LOCALE_JSON = JSON.stringify({ list: { title: 'Contacts', columns: { firstName: 'First name' } }, form: { fields: { lastName: 'Last name' } }, breadcrumb: { section: 'Contacts' }, }) function setupProject(): string { const dir = mkdtempSync(join(tmpdir(), 'validate-page-')) writeFileSync(join(dir, 'tsconfig.json'), TSCONFIG) mkdirSync(join(dir, 'src/components/ui'), { recursive: true }) writeFileSync(join(dir, 'src/components/ui/PageTemplate.tsx'), PAGE_TEMPLATE) mkdirSync(join(dir, 'src/features/crm/contact/hooks'), { recursive: true }) mkdirSync(join(dir, 'src/features/crm/contact/services'), { recursive: true }) mkdirSync(join(dir, 'src/features/crm/contact/types'), { recursive: true }) writeFileSync(join(dir, 'src/features/crm/contact/hooks/useContact.ts'), HOOK_FILE) writeFileSync(join(dir, 'src/features/crm/contact/services/contactService.ts'), SERVICE_FILE) writeFileSync(join(dir, 'src/features/crm/contact/types/index.ts'), TYPES_FILE) for (const locale of ['fr', 'en', 'it', 'de']) { // Canonical layout: one JSON per module under locales/, named after the // i18next namespace (here the page uses useTranslation('contact')). mkdirSync(join(dir, `src/i18n/locales/${locale}`), { recursive: true }) writeFileSync(join(dir, `src/i18n/locales/${locale}/contact.json`), LOCALE_JSON) } mkdirSync(join(dir, 'src/pages/myapp/crm/contacts'), { recursive: true }) return dir } function writePage(content: string, file = 'src/pages/myapp/crm/contacts/ContactListPage.tsx'): string { const abs = join(projectPath, file) writeFileSync(abs, content) return abs } beforeAll(() => { projectPath = setupProject() }) afterAll(() => { if (projectPath) rmSync(projectPath, { recursive: true, force: true }) }) const HEALTHY_PAGE = `// @generated-by scaffold-component — DO NOT HAND-EDIT (re-run the CLI to update) import { useTranslation } from 'react-i18next' import { PermissionGuard } from '@atlashub/smartstack' import { PageTemplate } from '@/components/ui/PageTemplate' import { useContact } from '@/features/crm/contact/hooks/useContact' export function ContactListPage() { const { t } = useTranslation('contact') const { data } = useContact() return ( ) } ` describe('validate-page / execute — healthy page passes all rules', () => { it('returns zero violations on a fully-conformant page', () => { writePage(HEALTHY_PAGE) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.module).toBe('crm') expect(r.entity).toBe('Contact') expect(r.view).toBe('list') expect(r.violations).toEqual([]) }) }) describe('validate-page / rule: cli-generated-marker', () => { it('flags a page that lacks the @generated-by header', () => { writePage(HEALTHY_PAGE.replace('// @generated-by scaffold-component — DO NOT HAND-EDIT (re-run the CLI to update)\n', '')) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'cli-generated-marker')).toBe(true) }) }) describe('validate-page / rule: imports-resolve', () => { it('flags a relative import that does not resolve', () => { writePage(HEALTHY_PAGE.replace( "import { PageTemplate } from '@/components/ui/PageTemplate'", "import { PageTemplate } from '@/components/ui/MissingTemplate'", )) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'imports-resolve')).toBe(true) }) }) describe('validate-page / rule: pagetemplate-wrapping', () => { it('flags a page that does not import PageTemplate from the canonical path', () => { writePage(`import { useTranslation } from 'react-i18next'\nexport function Page(){ return
}\n`) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'pagetemplate-wrapping')).toBe(true) }) }) describe('validate-page / rule: useparams-null-check', () => { it('flags useParams<…>() without a null guard', () => { writePage(`import { useParams } from 'react-router-dom' import { PageTemplate } from '@/components/ui/PageTemplate' import { useContact } from '@/features/crm/contact/hooks/useContact' export function ContactListPage(){ const { id } = useParams<{ id: string }>() const { data } = useContact(id) return {data?.id} } `) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'useparams-null-check')).toBe(true) }) it('passes when the guard is present', () => { writePage(`import { useParams, Navigate } from 'react-router-dom' import { PageTemplate } from '@/components/ui/PageTemplate' import { useContact } from '@/features/crm/contact/hooks/useContact' export function ContactListPage(){ const { id } = useParams<{ id: string }>() if (!id) return const { data } = useContact(id) return {data?.id} } `) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'useparams-null-check')).toBe(false) }) }) describe('validate-page / rule: permissionguard-on-mutations', () => { it('flags a mutateAsync call not wrapped in PermissionGuard', () => { writePage(`import { PageTemplate } from '@/components/ui/PageTemplate' import { useDeleteContact } from '@/features/crm/contact/hooks/useContact' export function ContactListPage(){ const del = useDeleteContact() return } `) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'permissionguard-on-mutations')).toBe(true) }) it('does NOT flag the generated shape: mutateAsync in a handler + a guarded button (decoupled JSX)', () => { // Mirrors scaffold-component output: the mutation call sits in handleDelete (above the // return), while the trigger button is wrapped in in the JSX below. writePage(`import { PermissionGuard } from '@atlashub/smartstack' import { PageTemplate } from '@/components/ui/PageTemplate' import { useDeleteContact } from '@/features/crm/contact/hooks/useContact' export function ContactListPage(){ const del = useDeleteContact() const handleDelete = async (item: { id: string }) => { await del.mutateAsync(item.id) } return ( ) } `) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'permissionguard-on-mutations')).toBe(false) }) }) describe('validate-page / rule: no-local-usepermissions', () => { it('flags a local function usePermissions() definition', () => { writePage(`import { PageTemplate } from '@/components/ui/PageTemplate' function usePermissions() { return { canEdit: true } } export function ContactListPage(){ const { canEdit } = usePermissions() return {canEdit ? 'yes' : 'no'} } `) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'no-local-usepermissions')).toBe(true) }) it('flags a const usePermissions = … definition', () => { writePage(`import { PageTemplate } from '@/components/ui/PageTemplate' const usePermissions = () => ({ canEdit: true }) export function ContactListPage(){ return x } `) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'no-local-usepermissions')).toBe(true) }) }) describe('validate-page / rule: i18n-keys-resolve', () => { it('flags a t() key missing in some locale files', () => { writePage(HEALTHY_PAGE.replace("t('list.title')", "t('list.titleMissingEverywhere')")) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) const i18nViolations = r.violations.filter((v) => v.rule === 'i18n-keys-resolve') expect(i18nViolations.length).toBe(4) // one per locale }) }) describe('validate-page / rule: i18n-keys-resolve — module catalogue + defaultValue', () => { // Mirror the REAL scaffold-component layout: one JSON per MODULE under // locales/, keys nested under the entity, page on the module namespace. const CRM_JSON = JSON.stringify({ contact: { list: { title: 'Contacts', actionsColumn: 'Actions', edit: 'Modifier' }, breadcrumb: { section: 'Contacts' }, }, }) function writeModuleCatalogue(json: string): void { for (const locale of ['fr', 'en', 'it', 'de']) { mkdirSync(join(projectPath, `src/i18n/locales/${locale}`), { recursive: true }) writeFileSync(join(projectPath, `src/i18n/locales/${locale}/crm.json`), json) } } const MODULE_PAGE = `// @generated-by scaffold-component — DO NOT HAND-EDIT (re-run the CLI to update) import { useTranslation } from 'react-i18next' import { PageTemplate } from '@/components/ui/PageTemplate' import { useContact } from '@/features/crm/contact/hooks/useContact' export function ContactListPage() { const { t } = useTranslation('crm') useContact() return ( {t('contact.list.actionsColumn')} ) } ` it('resolves entity-prefixed keys against the module catalogue (no false positives)', () => { writeModuleCatalogue(CRM_JSON) writePage(MODULE_PAGE) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.filter((v) => v.rule === 'i18n-keys-resolve')).toEqual([]) }) it('flags a structural key absent from the module catalogue (one per locale)', () => { writeModuleCatalogue(CRM_JSON) // has no contact.list.delete writePage(MODULE_PAGE.replace("t('contact.list.actionsColumn')", "t('contact.list.delete')")) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) const v = r.violations.filter((x) => x.rule === 'i18n-keys-resolve') expect(v.length).toBe(4) expect(v[0].message).toContain('contact.list.delete') expect(v[0].message).toContain('src/i18n/locales/') }) it('does NOT flag a t() call carrying a defaultValue, even when its key is absent', () => { writeModuleCatalogue(CRM_JSON) // has no contact.form.required writePage(MODULE_PAGE.replace( "{t('contact.list.actionsColumn')}", "{t('contact.form.required', { defaultValue: 'This field is required' })}", )) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.filter((v) => v.rule === 'i18n-keys-resolve')).toEqual([]) }) }) describe('validate-page / rule: hook-imports-exist', () => { it('flags a named import that the target file does not export', () => { writePage(HEALTHY_PAGE.replace( "import { useContact } from '@/features/crm/contact/hooks/useContact'", "import { useDashboardContact } from '@/features/crm/contact/hooks/useContact'", ).replace('useContact()', 'useDashboardContact()')) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'hook-imports-exist' && v.message.includes('useDashboardContact'))).toBe(true) }) }) describe('validate-page / rule: permission-keys-no-appcode', () => { it('flags a 4-segment permission key (appCode prefix)', () => { writePage(HEALTHY_PAGE.replace('crm.directory.create', 'testv2.crm.directory.create')) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'permission-keys-no-appcode')).toBe(true) }) it('flags a 2-segment permission key (missing action)', () => { writePage(HEALTHY_PAGE.replace('crm.directory.create', 'crm.directory')) const r = execute({ projectPath, pageFile: 'src/pages/myapp/crm/contacts/ContactListPage.tsx' }) expect(r.violations.some((v) => v.rule === 'permission-keys-no-appcode')).toBe(true) }) })