Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -1438,7 +1456,8 @@ program
.argument('<schema>', 'Path to THS schema JSON file')
.option('--out <dir>', '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) {
Expand Down Expand Up @@ -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)`);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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');
22 changes: 22 additions & 0 deletions packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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');
26 changes: 26 additions & 0 deletions test/testCliGenerateUi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =');
Expand Down Expand Up @@ -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);
});
});
Loading