diff --git a/Readme.md b/Readme.md index 33f6bf3..57bc084 100644 --- a/Readme.md +++ b/Readme.md @@ -40,6 +40,11 @@ pnpm legacy:build-run ## Testing +Token Host Builder uses a two-layer quality model: + +- Builder framework tests: validate schema/generator/CLI/runtime behavior. +- Generated app tests: validate that produced apps behave correctly for their schema (canonical `job-board` is enforced in CI today). + Fast local suite (no local chain required): ```bash @@ -53,6 +58,27 @@ Local integration suite (requires `anvil` on PATH): pnpm test:integration ``` +Generated app test scaffold (issue #28 slice): + +```bash +pnpm th generate apps/example/job-board.schema.json --out artifacts/job-board --with-tests +cd artifacts/job-board/ui +pnpm test +``` + +Current integration coverage includes: + +- preview auto-deploy behavior and manifest publication checks, +- local faucet behavior checks, +- canonical `apps/example/job-board.schema.json` end-to-end assertions: + - Candidate CRUD flows, + - JobPosting paid-create enforcement, + - generated UI route health checks. + +Planned expansion: + +- generated apps emitted by `th generate` should include app-level test scaffolds/scripts so downstream repos can run schema-specific tests by default. + ## CI PRs run two required jobs: diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f78bd9f..36f716f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -108,6 +108,24 @@ function copyDir(srcDir: string, destDir: string) { } } +function addGeneratedUiTestScaffold(uiDir: string, templateDir: string) { + const scaffoldDir = path.join(templateDir, 'test-scaffold'); + if (!fs.existsSync(scaffoldDir)) { + throw new Error(`Missing test scaffold template at ${scaffoldDir}`); + } + + copyDir(scaffoldDir, uiDir); + + const packageJsonPath = path.join(uiDir, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const scripts = { ...(pkg.scripts || {}) }; + scripts.test = scripts.test || 'pnpm run test:contract && pnpm run test:ui'; + scripts['test:contract'] = scripts['test:contract'] || 'node tests/contract/smoke.mjs'; + scripts['test:ui'] = scripts['test:ui'] || 'node tests/ui/smoke.mjs'; + pkg.scripts = scripts; + fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); +} + function publishManifestToUiSite(uiSiteDir: string, manifestJson: string) { ensureDir(uiSiteDir); ensureDir(path.join(uiSiteDir, '.well-known', 'tokenhost')); @@ -1438,7 +1456,8 @@ program .argument('', 'Path to THS schema JSON file') .option('--out ', 'Output directory', 'artifacts') .option('--no-ui', 'Do not generate UI output') - .action((schemaPath: string, opts: { out: string; ui: boolean }) => { + .option('--with-tests', 'Emit generated app test scaffold', false) + .action((schemaPath: string, opts: { out: string; ui: boolean; withTests: boolean }) => { const input = readJsonFile(schemaPath); const structural = validateThsStructural(input); if (!structural.ok) { @@ -1475,6 +1494,11 @@ program ensureDir(path.dirname(thsTsPath)); fs.writeFileSync(thsTsPath, renderThsTs(schema)); + if (opts.withTests) { + addGeneratedUiTestScaffold(uiDir, templateDir); + console.log(`Wrote ui/tests/ (generated app test scaffold)`); + } + console.log(`Wrote ui/ (Next.js static export template)`); } diff --git a/packages/templates/next-export-ui/test-scaffold/tests/README.md b/packages/templates/next-export-ui/test-scaffold/tests/README.md new file mode 100644 index 0000000..1c30be8 --- /dev/null +++ b/packages/templates/next-export-ui/test-scaffold/tests/README.md @@ -0,0 +1,8 @@ +Generated app test scaffold + +This directory is emitted by `th generate --with-tests`. + +- `contract/smoke.mjs` validates baseline generated app contract test preconditions. +- `ui/smoke.mjs` validates baseline generated UI route/component preconditions. + +These are starter tests and are intended to be expanded with schema-specific assertions. diff --git a/packages/templates/next-export-ui/test-scaffold/tests/contract/smoke.mjs b/packages/templates/next-export-ui/test-scaffold/tests/contract/smoke.mjs new file mode 100644 index 0000000..9af905e --- /dev/null +++ b/packages/templates/next-export-ui/test-scaffold/tests/contract/smoke.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +function mustExist(root, relPath) { + const p = path.join(root, relPath); + assert.equal(fs.existsSync(p), true, `Missing required generated file: ${relPath}`); + return p; +} + +const root = process.cwd(); +const thsPath = mustExist(root, 'src/generated/ths.ts'); +mustExist(root, 'src/lib/app.ts'); +mustExist(root, 'src/lib/abi.ts'); + +const thsSource = fs.readFileSync(thsPath, 'utf-8'); +assert.match(thsSource, /export const ths = /, 'Generated THS export is missing.'); + +console.log('PASS contract smoke scaffold'); diff --git a/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs b/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs new file mode 100644 index 0000000..365053c --- /dev/null +++ b/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +function mustExist(root, relPath) { + const p = path.join(root, relPath); + assert.equal(fs.existsSync(p), true, `Missing required generated UI file: ${relPath}`); +} + +const root = process.cwd(); + +for (const relPath of [ + 'app/layout.tsx', + 'app/page.tsx', + 'app/[collection]/layout.tsx', + 'app/[collection]/page.tsx', + 'app/[collection]/new/page.tsx' +]) { + mustExist(root, relPath); +} + +console.log('PASS ui smoke scaffold'); diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js index 84e3144..a681ca3 100644 --- a/test/testCliGenerateUi.js +++ b/test/testCliGenerateUi.js @@ -68,6 +68,7 @@ describe('th generate (UI template)', function () { expect(fs.existsSync(path.join(outDir, 'ui', 'package.json'))).to.equal(true); expect(fs.existsSync(path.join(outDir, 'ui', 'app', 'page.tsx'))).to.equal(true); + expect(fs.existsSync(path.join(outDir, 'ui', 'tests'))).to.equal(false); const generatedThs = fs.readFileSync(path.join(outDir, 'ui', 'src', 'generated', 'ths.ts'), 'utf-8'); expect(generatedThs).to.include('export const ths ='); @@ -110,4 +111,29 @@ describe('th generate (UI template)', function () { expect(fs.existsSync(path.join(outDir, 'contracts', 'App.sol'))).to.equal(true); expect(fs.existsSync(path.join(outDir, 'ui'))).to.equal(false); }); + + it('emits generated app test scaffold with --with-tests', function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-ui-gen-tests-')); + const schemaPath = path.join(dir, 'schema.json'); + const outDir = path.join(dir, 'out'); + writeJson(schemaPath, minimalSchema()); + + const res = runTh(['generate', schemaPath, '--out', outDir, '--with-tests'], process.cwd()); + expect(res.status, res.stderr || res.stdout).to.equal(0); + + const uiDir = path.join(outDir, 'ui'); + expect(fs.existsSync(path.join(uiDir, 'tests', 'contract', 'smoke.mjs'))).to.equal(true); + expect(fs.existsSync(path.join(uiDir, 'tests', 'ui', 'smoke.mjs'))).to.equal(true); + + const pkg = JSON.parse(fs.readFileSync(path.join(uiDir, 'package.json'), 'utf-8')); + expect(pkg?.scripts?.test).to.equal('pnpm run test:contract && pnpm run test:ui'); + expect(pkg?.scripts?.['test:contract']).to.equal('node tests/contract/smoke.mjs'); + expect(pkg?.scripts?.['test:ui']).to.equal('node tests/ui/smoke.mjs'); + + const contractSmoke = runCmd('node', ['tests/contract/smoke.mjs'], uiDir); + expect(contractSmoke.status, contractSmoke.stderr || contractSmoke.stdout).to.equal(0); + + const uiSmoke = runCmd('node', ['tests/ui/smoke.mjs'], uiDir); + expect(uiSmoke.status, uiSmoke.stderr || uiSmoke.stdout).to.equal(0); + }); });