/**
* 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')
}
})
})