/** * scaffold-tests — CLI entry contract. `index.ts` used to validate with Zod * and then hand the RAW spec to the semantic checks and to generate(): every * documented default (`businessRules`, `tenantMode`, `fields[].required`, * `hasCreate`) was absent at runtime — a business spec without `businessRules` * died on `.length` with a stack trace on stdout (non-JSON, exit 1), and on the * layers that survived the defaults never reached the emitted tests. These * tests run the real entry point on the documented invocation. */ import { describe, it, expect, afterAll } from 'vitest' import { mkdtempSync, readFileSync, rmSync } 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' import { ScaffoldTestsInputSchema } from '../types.js' const HERE = dirname(fileURLToPath(import.meta.url)) const INDEX = resolve(HERE, '../index.ts') const SKILL_MD = resolve(HERE, '../../../SKILL.md') /** A real directory: validate() refuses a projectPath that does not exist. */ const PROJECT = mkdtempSync(join(tmpdir(), 'scaffold-tests-entry-')) const tempDirs: string[] = [PROJECT] function outdir(): string { const d = mkdtempSync(join(tmpdir(), 'scaffold-tests-out-')) tempDirs.push(d) return d } afterAll(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }) }) /** The `--spec` JSON of the `## CLI: scaffold-tests` block, read from testing/SKILL.md itself. */ function docExample(): Record { const md = readFileSync(SKILL_MD, 'utf-8') const start = md.indexOf('## CLI: scaffold-tests\n') expect(start, 'testing/SKILL.md lost its `## CLI: scaffold-tests` section').toBeGreaterThanOrEqual(0) const section = md.slice(start, md.indexOf('\n## ', start + 1)) const m = /--spec '(\{[\s\S]*?\})'/.exec(section) expect(m, 'the scaffold-tests section carries no --spec example').not.toBeNull() return JSON.parse(m![1]) } /** Dotted key paths of a JSON value (arrays indexed) — the no-strip comparison. */ function keyPaths(value: unknown, prefix = ''): string[] { if (Array.isArray(value)) return value.flatMap((v, i) => keyPaths(v, `${prefix}[${i}]`)) if (value !== null && typeof value === 'object') { return Object.entries(value as Record).flatMap(([k, v]) => { const p = prefix ? `${prefix}.${k}` : k return [p, ...keyPaths(v, p)] }) } return [] } const posix = (p: string) => p.replace(/\\/g, '/') /** Business layer, NO businessRules, NO tenantMode, NO fields[].required — every default left to Zod. */ const BUSINESS_NO_RULES = { layer: 'business', module: 'parametrage', appCode: 'Demo', applicationCode: 'flotte', entities: [{ name: 'JourFerie', pluralName: 'JoursFeries', fields: [{ name: 'Label', type: 'string' }] }], projectPath: PROJECT, } describe('scaffold-tests — the CLI entry runs the PARSED spec', () => { it('FIRES: a business spec WITHOUT businessRules answers a JSON envelope (it crashed on `businessRules.length`)', () => { const r = runCli(INDEX, ['--spec', JSON.stringify(BUSINESS_NO_RULES), '--dry_run']) expect(r.envelope, `${r.stdout}\n${r.stderr}`).not.toBeNull() expect(r.stdout).not.toContain('TypeError') 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.files.some((f: string) => f.endsWith('Application/JourFerieServiceTests.cs'))).toBe(true) // The semantic check ran on the PARSED spec: the default `[]` is what it saw. expect(r.envelope.warnings).toContain('No business rules provided — only validator tests will be generated') }) it('defaults reach generate(): a domain spec omitting tenantMode and fields[].required emits the required/strict shape', () => { const out = outdir() const r = runCli(INDEX, ['--spec', JSON.stringify({ ...BUSINESS_NO_RULES, layer: 'domain' }), '--outdir', out]) expect(r.envelope, `${r.stdout}\n${r.stderr}`).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) const file = (r.envelope.filesCreated as string[]).find(p => posix(p).endsWith('Domain/JourFerieTests.cs')) expect(file, JSON.stringify(r.envelope.filesCreated)).toBeDefined() const src = readFileSync(file!, 'utf-8') // required:true (default) → Label is a factory argument and is asserted; // tenantMode:'strict' (default) → the implicit trailing tenantId argument. expect(src).toContain('JourFerie.Create("Test", Guid.NewGuid())') expect(src).toContain('entity.Label.Should()') // A required STRING field → the ArgumentException guard test is emitted. expect(src).toContain('Create_WithInvalidParams_ShouldThrow') // What the RAW spec emitted (required undefined → no field, only the tenant arg). expect(src).not.toContain('JourFerie.Create(Guid.NewGuid())') }) it('defaults reach generate(): a business spec omitting tenantMode gets the tenant-aware service tests', () => { const out = outdir() const r = runCli(INDEX, ['--spec', JSON.stringify(BUSINESS_NO_RULES), '--outdir', out]) expect(r.envelope, `${r.stdout}\n${r.stderr}`).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) const file = (r.envelope.filesCreated as string[]).find(p => posix(p).endsWith('Application/JourFerieServiceTests.cs')) expect(file, JSON.stringify(r.envelope.filesCreated)).toBeDefined() const src = readFileSync(file!, 'utf-8') expect(src).toContain('new JourFerieService(context, tenantService)') expect(src).toContain('#region Tenant isolation') // required:true (default) → Label is the Create command argument. expect(src).toContain('new CreateJourFerieCommand("Test")') }) it('validate() returns the parsed spec with the documented defaults applied', () => { const v = validate(BUSINESS_NO_RULES) expect(v.valid, JSON.stringify(v.errors)).toBe(true) expect(v.data?.tenantMode).toBe('strict') expect(v.data?.businessRules).toEqual([]) expect(v.data?.entities[0].hasCreate).toBe(true) expect(v.data?.entities[0].fields[0].required).toBe(true) }) it('validate() carries no data when the spec is refused — structurally or semantically', () => { const structural = validate({ ...BUSINESS_NO_RULES, layer: 'unit' }) expect(structural.valid).toBe(false) expect(structural.data).toBeUndefined() expect(structural.errors[0]).toMatch(/^\[layer\]/) const missing = join(PROJECT, 'does-not-exist') const semantic = validate({ ...BUSINESS_NO_RULES, projectPath: missing }) expect(semantic.valid).toBe(false) expect(semantic.data).toBeUndefined() expect(semantic.errors).toEqual([`Project path not found: ${missing}`]) }) it('a refused spec still answers a JSON envelope with errors[] (never a stack trace)', () => { const r = runCli(INDEX, ['--spec', JSON.stringify({ ...BUSINESS_NO_RULES, entities: [] }), '--dry_run']) expect(r.envelope, `${r.stdout}\n${r.stderr}`).not.toBeNull() expect(r.status).toBe(1) expect(r.envelope.success).toBe(false) expect(Array.isArray(r.envelope.errors)).toBe(true) expect(r.envelope.errors[0]).toMatch(/^\[entities\]/) }) describe('the documented SKILL.md example', () => { it('is Zod-valid and runs end to end in --dry_run', () => { // `/path` in the doc is illustrative — validate() needs a directory that exists. const example = { ...docExample(), projectPath: PROJECT } const r = runCli(INDEX, ['--spec', JSON.stringify(example), '--dry_run']) expect(r.envelope, `${r.stdout}\n${r.stderr}`).not.toBeNull() expect(r.envelope.success, JSON.stringify(r.envelope.errors)).toBe(true) expect(r.envelope.data.files.some((f: string) => f.endsWith('Application/EmployeeServiceTests.cs'))).toBe(true) }) it('no-strip: every key of the documented spec survives parsing into validation.data', () => { const example = { ...docExample(), projectPath: PROJECT } const v = validate(example) expect(v.valid, JSON.stringify(v.errors)).toBe(true) const parsed = new Set(keyPaths(v.data)) for (const p of keyPaths(example)) expect(parsed.has(p), `key ${p} was stripped by parsing`).toBe(true) }) it('parity: a spec carrying every value explicitly generates the same files with or without parse', () => { const complete = JSON.parse(JSON.stringify(ScaffoldTestsInputSchema.parse({ ...docExample(), projectPath: PROJECT }))) expect(generate(complete)).toEqual(generate(ScaffoldTestsInputSchema.parse(complete))) }) }) })