/** * scaffold-component — the three entry flags nobody tested (audit 2026-09-24, * A-WARN-11), run through the REAL index.ts against a temp web root: * * --dry_run nothing written; the envelope still lists what would be * --pages-dir pages relocated (relative or absolute INSIDE the web * root), i18n stays under src/i18n/, traversal refused * --reset-i18n-entity ONE entity's i18n subtree rebuilt from floor + PRD * (stale keys dropped, siblings verbatim); without the * flag the existing subtree wins over the floor * * Every behaviour has its positive and its negative case; a `@customised` * page is never overwritten and lands in `data.skipped[]`. */ import { describe, it, expect, afterEach } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { runCli } from '../../../../../../lib/__tests__/helpers/run-cli.js' import { validate } from '../validate.js' import { generate } from '../generate.js' const INDEX = resolve(dirname(fileURLToPath(import.meta.url)), '../index.ts') const UI_IMPORT_RE = /from '@\/components\/ui\/([A-Za-z0-9]+)'/g const norm = (p: string): string => p.replace(/\\/g, '/') /** The invocation documented in frontend/component/SKILL.md (list/detail/form by default). */ function spec(projectPath: string) { return { module: 'hrm', appCode: 'myapp', entity: 'Employee', section: 'employees', fields: [{ name: 'firstName', type: 'string' }], projectPath, } } /** What generate() emits for the spec: the default page paths + the i18n catalogues. */ function emitted(projectPath: string) { const files = generate(validate(spec(projectPath)).data!) return { files, pages: files.filter(f => /\.tsx$/i.test(f.path) && f.path.includes('/pages/')).map(f => f.path), i18n: files.filter(f => /\/i18n\/locales\//.test(f.path)).map(f => f.path), } } const roots: string[] = [] afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive: true, force: true }) }) /** * A web root that passes every fail-closed gate of index.ts: package.json * (assertWebProjectRoot) + one stub per UI primitive the output imports * (checkPrimitivePreflight). Nothing else — no registry, no i18n. */ function makeWebRoot(): string { const root = mkdtempSync(join(tmpdir(), 'ss-component-flags-')) roots.push(root) writeFileSync(join(root, 'package.json'), '{ "name": "web", "private": true }\n', 'utf-8') const needed = new Set() for (const f of emitted(root).files) for (const m of f.content.matchAll(UI_IMPORT_RE)) needed.add(m[1]) mkdirSync(join(root, 'src', 'components', 'ui'), { recursive: true }) for (const p of needed) writeFileSync(join(root, 'src', 'components', 'ui', `${p}.tsx`), `export const ${p} = () => null\n`, 'utf-8') return root } function run(root: string, extra: string[] = []) { const r = runCli(INDEX, ['--spec', JSON.stringify(spec(root)), ...extra]) expect(r.envelope, r.stderr).not.toBeNull() return r } describe('scaffold-component / --dry_run', () => { it('writes NOTHING but the envelope lists every page and i18n file it would write', () => { const root = makeWebRoot() const before = readdirSync(join(root, 'src')) const r = run(root, ['--dry_run']) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.status).toBe(0) expect(r.envelope.data.dryRun).toBe(true) expect(r.envelope.data.pagesDirArg).toBeNull() expect(r.envelope.filesCreated).toEqual([]) const listed: string[] = r.envelope.data.files.map(norm) const { pages, i18n } = emitted(root) for (const p of [...pages, ...i18n]) expect(listed).toContain(norm(p)) // Only the fixture's own src/components survives — no pages, no i18n, no features. expect(readdirSync(join(root, 'src'))).toEqual(before) expect(existsSync(join(root, 'src', 'i18n'))).toBe(false) }) it('without --dry_run the same invocation writes the pages and the four catalogues', () => { const root = makeWebRoot() const r = run(root) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.dryRun).toBeUndefined() const created: string[] = r.envelope.filesCreated.map(norm) const { pages, i18n } = emitted(root) for (const p of [...pages, ...i18n]) { const abs = norm(resolve(root, p)) expect(created).toContain(abs) expect(existsSync(abs)).toBe(true) } expect(r.envelope.data.fileCount).toBe(pages.length + i18n.length) }) }) describe('scaffold-component / --pages-dir', () => { it('a RELATIVE --pages-dir relocates the pages under /; i18n stays under src/i18n/', () => { const root = makeWebRoot() const r = run(root, ['--pages-dir', 'src/pages/legacy/employees']) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.pagesDir).toBe('src/pages/legacy/employees') const created: string[] = r.envelope.filesCreated.map(norm) const { pages, i18n } = emitted(root) for (const p of pages) { const base = p.split('/').pop()! const relocated = norm(resolve(root, 'src/pages/legacy/employees', base)) expect(created).toContain(relocated) expect(existsSync(relocated)).toBe(true) // The default layout location is NOT written. expect(existsSync(resolve(root, p))).toBe(false) } for (const p of i18n) expect(existsSync(resolve(root, p))).toBe(true) }) it('an ABSOLUTE --pages-dir inside the web root writes there (the ui-polish regenerate-in-place path)', () => { const root = makeWebRoot() const target = join(root, 'src', 'pages', 'inplace') const r = run(root, ['--pages-dir', target]) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.pagesDir).toBe(target) for (const p of emitted(root).pages) { expect(existsSync(join(target, p.split('/').pop()!))).toBe(true) } }) it('an ABSOLUTE --pages-dir OUTSIDE the web root is refused as a traversal — nothing lands there', () => { const root = makeWebRoot() const outside = mkdtempSync(join(tmpdir(), 'ss-component-flags-outside-')) roots.push(outside) const r = run(root, ['--pages-dir', outside]) expect(r.envelope.success).toBe(false) expect(r.status).toBe(1) expect(r.envelope.errors.join('\n')).toMatch(/Path traversal/) expect(readdirSync(outside).filter(f => /\.tsx$/i.test(f))).toEqual([]) }) it('in --dry_run the listed page paths carry the --pages-dir rewrite and pagesDirArg echoes it', () => { const root = makeWebRoot() const r = run(root, ['--dry_run', '--pages-dir', 'regen']) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.pagesDirArg).toBe('regen') const listed: string[] = r.envelope.data.files.map(norm) const { pages, i18n } = emitted(root) for (const p of pages) expect(listed).toContain(norm(join('regen', p.split('/').pop()!))) for (const p of pages) expect(listed).not.toContain(norm(p)) for (const p of i18n) expect(listed).toContain(norm(p)) expect(existsSync(join(root, 'regen'))).toBe(false) }) it('a @customised page at the --pages-dir target is preserved and reported in data.skipped[]', () => { const root = makeWebRoot() const dir = join(root, 'src', 'pages', 'custom') mkdirSync(dir, { recursive: true }) const { pages } = emitted(root) const listBase = pages.map(p => p.split('/').pop()!).find(b => b.endsWith('ListPage.tsx'))! const bespoke = '// @customised — hand-tuned list (ui-design pass)\nexport default function Bespoke() { return null }\n' writeFileSync(join(dir, listBase), bespoke, 'utf-8') const r = run(root, ['--pages-dir', dir]) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(readFileSync(join(dir, listBase), 'utf-8')).toBe(bespoke) expect(r.envelope.data.skipped.map(norm)).toEqual([norm(join(dir, listBase))]) expect(r.envelope.filesCreated.map(norm)).not.toContain(norm(join(dir, listBase))) // The other pages of the run are still written next to it. for (const b of pages.map(p => p.split('/').pop()!).filter(b => b !== listBase)) { expect(r.envelope.filesCreated.map(norm)).toContain(norm(join(dir, b))) } }) it('an UNMARKED existing page at the --pages-dir target is overwritten (the regeneration contract)', () => { const root = makeWebRoot() const dir = join(root, 'src', 'pages', 'stale') mkdirSync(dir, { recursive: true }) const { pages } = emitted(root) const listBase = pages.map(p => p.split('/').pop()!).find(b => b.endsWith('ListPage.tsx'))! writeFileSync(join(dir, listBase), 'export default function Stale() { return null }\n', 'utf-8') const r = run(root, ['--pages-dir', dir]) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.skipped).toEqual([]) expect(readFileSync(join(dir, listBase), 'utf-8')).not.toContain('function Stale()') expect(r.envelope.filesCreated.map(norm)).toContain(norm(join(dir, listBase))) }) }) describe('scaffold-component / --reset-i18n-entity', () => { const FR = 'src/i18n/locales/fr/hrm.json' const seeded = { employee: { list: { subtitle: 'Ancien sous-titre PRD' }, custom: { staleKey: 'clé périmée' }, }, sibling: { list: { title: 'Entité voisine' } }, } function seedFr(root: string): void { mkdirSync(dirname(join(root, FR)), { recursive: true }) writeFileSync(join(root, FR), JSON.stringify(seeded, null, 2) + '\n', 'utf-8') } const readFr = (root: string) => JSON.parse(readFileSync(join(root, FR), 'utf-8')) it('WITHOUT the flag the existing entity subtree wins over the floor and stale keys linger', () => { const root = makeWebRoot() seedFr(root) const r = run(root) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.resetI18nEntity).toBe(false) const fr = readFr(root) expect(fr.employee.list.subtitle).toBe('Ancien sous-titre PRD') expect(fr.employee.custom.staleKey).toBe('clé périmée') // The floor still fills the gaps and the sibling is carried verbatim. expect(fr.employee.list.title).toBeDefined() expect(fr.sibling).toEqual(seeded.sibling) }) it('WITH the flag the entity subtree is rebuilt from the floor: stale keys dropped, siblings verbatim', () => { const root = makeWebRoot() seedFr(root) const r = run(root, ['--reset-i18n-entity']) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.resetI18nEntity).toBe(true) const fr = readFr(root) expect(fr.employee.list.subtitle).toBeDefined() expect(fr.employee.list.subtitle).not.toBe('Ancien sous-titre PRD') expect(fr.employee.custom?.staleKey).toBeUndefined() expect(fr.sibling).toEqual(seeded.sibling) }) it('the reset is scoped to the target entity: a second entity of the module keeps its subtree', () => { const root = makeWebRoot() seedFr(root) const r = run(root, ['--reset-i18n-entity']) expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) const fr = readFr(root) expect(Object.keys(fr).sort()).toEqual(['employee', 'sibling']) expect(fr.sibling.list.title).toBe('Entité voisine') }) it('a malformed existing catalogue is refused (fail-closed) with or without the flag', () => { for (const extra of [[], ['--reset-i18n-entity']]) { const root = makeWebRoot() mkdirSync(dirname(join(root, FR)), { recursive: true }) writeFileSync(join(root, FR), '{ not json', 'utf-8') const r = run(root, extra) expect(r.envelope.success).toBe(false) expect(r.envelope.errors.join('\n')).toMatch(/not a valid JSON object/) expect(readFileSync(join(root, FR), 'utf-8')).toBe('{ not json') } }) })