import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' describe('scaffold-layout / generate', () => { const spec = { projectPath: '/web', appCode: 'TestV2', force: false } it('writes PageTemplate at src/components/ui/PageTemplate.tsx (not the legacy src/components path)', () => { const files = generate(spec) const pt = files.find((f) => f.path.endsWith('PageTemplate.tsx')) expect(pt).toBeDefined() expect(pt!.path).toBe('src/components/ui/PageTemplate.tsx') // Negative: ensure the legacy non-ui path is gone — the path mismatch // (scaffold-layout writes here, scaffold-component imports from /ui/) // is the silent-spinner #1 root cause. expect(files.some((f) => f.path === 'src/components/PageTemplate.tsx')).toBe(false) }) it('declares all six props in PageTemplateProps', () => { const files = generate(spec) const pt = files.find((f) => f.path.endsWith('PageTemplate.tsx'))! // Each prop name must appear in the interface for (const prop of ['title', 'subtitle', 'icon', 'actions', 'children', 'className', 'contentClassName']) { expect(pt.content).toMatch(new RegExp(`\\b${prop}\\??:`)) } }) it('destructures and renders subtitle / icon / actions / className / contentClassName', () => { const files = generate(spec) const pt = files.find((f) => f.path.endsWith('PageTemplate.tsx'))! // Destructured in the component signature (multiline OK) expect(pt.content).toMatch(/title,[\s\S]*subtitle,[\s\S]*icon,[\s\S]*actions,[\s\S]*children,[\s\S]*className,[\s\S]*contentClassName/) // Each prop is rendered somewhere in JSX expect(pt.content).toMatch(/\{subtitle\s*&&/) expect(pt.content).toMatch(/\{icon\s*&&/) expect(pt.content).toMatch(/\{actions\s*&&/) expect(pt.content).toMatch(/className\s*\?\?\s*''/) expect(pt.content).toMatch(/contentClassName\s*\?\?\s*''/) }) it('emits PageTemplate ONLY — the chrome comes from @atlashub/smartstack', () => { // A locally emitted AppShell/Sidebar duplicated the package chrome, was // never mounted (App.tsx renders alone), and carried no // mobile branch — a pure drift source. The package owns the chrome. const files = generate(spec) expect(files.map((f) => f.path)).toEqual(['src/components/ui/PageTemplate.tsx']) expect(files.some((f) => f.path === 'src/components/AppShell.tsx')).toBe(false) expect(files.some((f) => f.path === 'src/components/Sidebar.tsx')).toBe(false) }) it('emits no chrome markup at all (no header/aside/nav shell in the output)', () => { const files = generate(spec) const all = files.map((f) => f.content).join('\n') expect(all).not.toContain(' { // Under `verbatimModuleSyntax: true`, `JSX` is not a global and would // need `import type { JSX } from 'react'`. Cleanest is to omit the // annotation — TS infers correctly from the JSX return. const files = generate(spec) for (const file of files.filter(f => f.path.endsWith('.tsx'))) { expect(file.content, file.path).not.toMatch(/\):\s*JSX\.Element\s*\{/) } }) it('Fix #6 — emits `import type { ReactNode }` (not `import { ReactNode }`)', () => { // Under verbatimModuleSyntax: true, value imports of types fail // compilation with `'ReactNode' is a type and must be imported using a // type-only import`. Force type-only imports everywhere ReactNode shows up. const files = generate(spec) for (const file of files.filter(f => f.path.endsWith('.tsx'))) { // Negative: a bare value import of ReactNode must not appear. expect(file.content, file.path).not.toMatch(/import\s*\{\s*ReactNode\b[^}]*\}\s*from\s*'react'/) } }) it('exposes the two-width contract (width prop, responsive cap, default standard)', () => { const files = generate(spec) const pt = files.find((f) => f.path.endsWith('PageTemplate.tsx'))! // The only two sanctioned widths. expect(pt.content).toMatch(/width\?\s*:\s*'standard'\s*\|\s*'focused'/) // Defaults to standard (full-width) so existing pages are unchanged. expect(pt.content).toMatch(/width\s*=\s*'standard'/) // focused → centered column capped on the responsive (rem) token, never a px literal. expect(pt.content).toContain('max-w-[var(--content-max-narrow)]') // The horizontal gutter lives on the scroll container (AppShell
), NOT here — // PageTemplate must NOT carry it, else it doubles inside an already-padded shell. expect(pt.content).not.toContain('var(--content-gutter-x)') }) it('gutter stays on the package scroll container, not PageTemplate (no double gutter)', () => { const files = generate(spec) const pt = files.find((f) => f.path.endsWith('PageTemplate.tsx'))! // The single horizontal gutter is applied ONCE by the package chrome // (desktop shell
, mobile shell content area) — never here, else it // doubles inside an already-padded shell. expect(pt.content).not.toContain('var(--content-gutter-x)') // PageTemplate keeps its vertical rhythm. expect(pt.content).toContain('py-6') }) }) /** * The header must give way by WRAPPING, never by crushing its actions. * * The row used to be a plain `flex items-center justify-between` whose title * block had no basis and whose actions block had neither wrap nor a min-content * floor. So the only thing that could give was the actions block: it shrank past * its own min-content and `.btn` (which carried no white-space rule) broke the * labels in two — "Grilles / concurrentes" on a tablet. */ describe('scaffold-layout / PageTemplate header — gives way by wrapping', () => { const spec = { projectPath: '/web', appCode: 'TestV2', force: false } const header = (): string => { const pt = generate(spec).find((f) => f.path.endsWith('PageTemplate.tsx'))! const start = pt.content.indexOf('') return pt.content.slice(start, end) } it('wraps the row and gives the title block a real flex-basis', () => { const h = header() expect(h).toMatch(/
/) // A bare `flex-1` means flex-basis 0: the title counts for nothing in the // wrap computation and the row never wraps. The basis IS the wrap trigger. expect(h).toMatch(/flex min-w-0 flex-1 basis-80 items-start/) }) it('floors the actions block at its min-content instead of pinning or crushing it', () => { const h = header() expect(h).toMatch(/className="ml-auto flex flex-wrap items-center gap-2"/) // `min-w-0` would let it shrink below one button; `shrink-0` would pin it at // max-content and push a horizontal scrollbar onto the page. expect(h).not.toMatch(/actions &&
{ const h = header() // Title and subtitle share one column — the subtitle used to hang to the // left of the title, under the icon. expect(h).toMatch(/
[\s\S]*/) expect(h).toMatch(/