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
6 changes: 5 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,13 @@ function addGeneratedUiTestScaffold(uiDir: string, templateDir: string) {
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:contract'] = scripts['test:contract'] || 'node tests/contract/integration.mjs';
scripts['test:ui'] = scripts['test:ui'] || 'node tests/ui/smoke.mjs';
pkg.scripts = scripts;
const devDependencies = { ...(pkg.devDependencies || {}) };
devDependencies.solc = devDependencies.solc || '0.8.24';
devDependencies.web3 = devDependencies.web3 || '^1.3.5';
pkg.devDependencies = devDependencies;
fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
}

Expand Down
11 changes: 10 additions & 1 deletion packages/templates/next-export-ui/test-scaffold/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ Generated app test scaffold

This directory is emitted by `th generate --with-tests`.

- `contract/integration.mjs` runs schema-driven contract behavior tests against local anvil.
- `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.
Contract integration prerequisites:
- local anvil RPC (default `http://127.0.0.1:8545`)
- generated `../contracts/App.sol` and `../schema.json`

Contract test env vars:
- `TH_RPC_URL` (optional)
- `TH_TEST_PRIVATE_KEY` (optional, defaults to anvil account #0 key)

These tests are schema-driven and intended to be expanded further for app-specific assertions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';

import Web3 from 'web3';

const require = createRequire(import.meta.url);
const solc = require('solc');

const DEFAULT_RPC_URL = 'http://127.0.0.1:8545';
const DEFAULT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';

function mustReadJson(filePath) {
if (!fs.existsSync(filePath)) throw new Error(`Missing file: ${filePath}`);
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}

function mustReadText(filePath) {
if (!fs.existsSync(filePath)) throw new Error(`Missing file: ${filePath}`);
return fs.readFileSync(filePath, 'utf-8');
}

function compileApp(source) {
const input = {
language: 'Solidity',
sources: {
'App.sol': { content: source }
},
settings: {
optimizer: { enabled: true, runs: 200 },
outputSelection: {
'*': {
'*': ['abi', 'evm.bytecode.object']
}
}
}
};

const output = JSON.parse(solc.compile(JSON.stringify(input)));
const errors = (output.errors || []).filter((e) => e.severity === 'error');
if (errors.length > 0) {
throw new Error(errors.map((e) => e.formattedMessage || e.message).join('\n'));
}

const app = output?.contracts?.['App.sol']?.App;
if (!app?.abi || !app?.evm?.bytecode?.object) {
throw new Error('Failed to compile App.sol (missing abi/bytecode).');
}
return { abi: app.abi, bytecode: `0x${app.evm.bytecode.object}` };
}

function sampleValue(field, idx, forUpdate, accountAddress) {
const suffix = `${forUpdate ? 'u' : 'c'}-${idx}`;
switch (field.type) {
case 'string':
case 'image':
return `${field.name}-${suffix}`;
case 'uint256':
case 'reference':
case 'decimal':
return String(1000 + idx);
case 'int256':
return String(-100 - idx);
case 'bool':
return idx % 2 === 0;
case 'address':
case 'externalReference':
return accountAddress;
case 'bytes32':
return `0x${'ab'.repeat(32)}`;
default:
throw new Error(`Unsupported field type for generated tests: ${field.type}`);
}
}

async function mustFail(promiseFactory, expectedHint) {
let failed = false;
try {
await promiseFactory();
} catch (e) {
failed = true;
if (expectedHint) {
const msg = String(e?.message ?? e);
assert.match(msg, expectedHint, `Expected error hint ${expectedHint}, got: ${msg}`);
}
}
assert.equal(failed, true, 'Expected operation to fail but it succeeded.');
}

async function main() {
const root = process.cwd();
const parent = path.resolve(root, '..');
const schemaPath = path.join(parent, 'schema.json');
const appSolPath = path.join(parent, 'contracts', 'App.sol');

const schema = mustReadJson(schemaPath);
const appSol = mustReadText(appSolPath);
const { abi, bytecode } = compileApp(appSol);

const rpcUrl = process.env.TH_RPC_URL || DEFAULT_RPC_URL;
const privateKey = process.env.TH_TEST_PRIVATE_KEY || DEFAULT_PRIVATE_KEY;
const web3 = new Web3(rpcUrl);

const listening = await web3.eth.net.isListening().catch(() => false);
if (!listening) {
throw new Error(`RPC is not reachable at ${rpcUrl}. Start anvil and retry.`);
}

const account = web3.eth.accounts.privateKeyToAccount(privateKey);
web3.eth.accounts.wallet.add(account);
web3.eth.defaultAccount = account.address;

const anyPaidCreates = (schema.collections || []).some((c) => Boolean(c?.createRules?.payment));
const deployArgs = anyPaidCreates ? [account.address, account.address] : [];

const app = await new web3.eth.Contract(abi)
.deploy({ data: bytecode, arguments: deployArgs })
.send({ from: account.address, gas: 8_000_000 });

for (const collection of schema.collections || []) {
const name = String(collection.name);
const fields = Array.isArray(collection.fields) ? collection.fields : [];
const mutable = Array.isArray(collection?.updateRules?.mutable) ? collection.updateRules.mutable : [];
const softDelete = Boolean(collection?.deleteRules?.softDelete);
const hasTransfer = Boolean(collection?.transferRules);
const hasPayment = Boolean(collection?.createRules?.payment?.amountWei);
const optimistic = Boolean(collection?.updateRules?.optimisticConcurrency);

const createFn = `create${name}`;
const listFn = `listIds${name}`;
const getFn = `get${name}(uint256)`;
const getWithDeletedFn = `get${name}(uint256,bool)`;
const updateFn = `update${name}`;
const deleteFn = `delete${name}`;
const transferFn = `transfer${name}`;

const createArgs = fields.map((f, idx) => sampleValue(f, idx, false, account.address));

if (hasPayment) {
await mustFail(() =>
app.methods[createFn](...createArgs).send({ from: account.address, gas: 3_000_000 })
);

await app.methods[createFn](...createArgs).send({
from: account.address,
gas: 3_000_000,
value: String(collection.createRules.payment.amountWei)
});
} else {
await app.methods[createFn](...createArgs).send({ from: account.address, gas: 3_000_000 });
}

const ids = await app.methods[listFn](0, 20, false).call();
assert.equal(Array.isArray(ids), true, `${listFn} must return an array`);
assert.equal(ids.length > 0, true, `${listFn} must include created record`);
const id = Number(ids[0]);

const current = await app.methods[getFn](id).call();
assert.ok(current, `${getFn} should return a record`);

if (hasTransfer) {
const accounts = await web3.eth.getAccounts();
const to = accounts[1] || account.address;
await app.methods[transferFn](id, to).send({ from: account.address, gas: 3_000_000 });
const afterTransfer = await app.methods[getFn](id).call();
assert.equal(
String(afterTransfer.owner || '').toLowerCase(),
String(to).toLowerCase(),
`${transferFn} should update owner`
);
}

if (mutable.length > 0) {
const updateArgs = [id];
for (const mutableFieldName of mutable) {
const field = fields.find((f) => f?.name === mutableFieldName);
if (!field) continue;
updateArgs.push(sampleValue(field, 777, true, account.address));
}
if (optimistic) updateArgs.push('0');

await app.methods[updateFn](...updateArgs).send({ from: account.address, gas: 3_000_000 });
const afterUpdate = await app.methods[getFn](id).call();
const firstMutable = mutable[0];
const firstMutableField = fields.find((f) => f.name === firstMutable);
if (firstMutable && firstMutable in afterUpdate && firstMutableField) {
assert.equal(
String(afterUpdate[firstMutable]),
String(sampleValue(firstMutableField, 777, true, account.address)),
`${updateFn} should update mutable field ${firstMutable}`
);
}
}

if (softDelete) {
await app.methods[deleteFn](id).send({ from: account.address, gas: 3_000_000 });
const deletedRecord = await app.methods[getWithDeletedFn](id, true).call();
assert.equal(Boolean(deletedRecord.isDeleted), true, `${deleteFn} should mark isDeleted`);

const activeIds = await app.methods[listFn](0, 20, false).call();
const hasId = (activeIds || []).map((x) => String(x)).includes(String(id));
assert.equal(hasId, false, `${listFn} should exclude soft-deleted record by default`);
}
}

console.log('PASS contract integration scaffold');
}

main().catch((e) => {
console.error(String(e?.stack || e?.message || e));
process.exit(1);
});
85 changes: 85 additions & 0 deletions test/integration/testGeneratedAppContractTests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { expect } from 'chai';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawn, spawnSync } from 'child_process';

function runTh(args, cwd) {
return spawnSync('node', [path.resolve('packages/cli/dist/index.js'), ...args], {
cwd,
encoding: 'utf-8'
});
}

function runCmd(cmd, args, cwd, extraEnv = {}) {
return spawnSync(cmd, args, {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...extraEnv }
});
}

function hasAnvil() {
const res = spawnSync('anvil', ['--version'], { encoding: 'utf-8' });
if (res.error && res.error.code === 'ENOENT') return false;
return res.status === 0;
}

async function tryGetChainIdHex(rpcUrl) {
const res = await fetch(rpcUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] })
});
if (!res.ok) return null;
const json = await res.json();
return typeof json?.result === 'string' ? json.result : null;
}

async function waitForRpc(rpcUrl, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const hex = await tryGetChainIdHex(rpcUrl);
if (hex) return hex;
} catch {
// continue polling
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Timed out waiting for RPC at ${rpcUrl}`);
}

describe('Generated app contract tests', function () {
it('emits and runs schema-driven contract integration tests for canonical job-board output', async function () {
this.timeout(240000);
if (!hasAnvil()) this.skip();

const schemaPath = path.join(process.cwd(), 'apps', 'example', 'job-board.schema.json');
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-generated-app-tests-'));
const uiDir = path.join(outDir, 'ui');

const generateRes = runTh(['generate', schemaPath, '--out', outDir, '--with-tests'], process.cwd());
expect(generateRes.status, generateRes.stderr || generateRes.stdout).to.equal(0);

const installRes = runCmd('pnpm', ['install'], uiDir, { NEXT_TELEMETRY_DISABLED: '1' });
expect(installRes.status, installRes.stderr || installRes.stdout).to.equal(0);

const port = 45000 + Math.floor(Math.random() * 1000);
const rpcUrl = `http://127.0.0.1:${port}`;
const anvil = spawn('anvil', ['--host', '127.0.0.1', '--port', String(port), '--chain-id', '31337'], {
stdio: ['ignore', 'pipe', 'pipe']
});

try {
await waitForRpc(rpcUrl, 15000);
const testRes = runCmd('node', ['tests/contract/integration.mjs'], uiDir, {
TH_RPC_URL: rpcUrl
});
expect(testRes.status, testRes.stderr || testRes.stdout).to.equal(0);
expect(testRes.stdout).to.include('PASS contract integration scaffold');
} finally {
anvil.kill('SIGINT');
}
});
});
5 changes: 4 additions & 1 deletion test/testCliGenerateUi.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,15 @@ describe('th generate (UI template)', function () {

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', 'contract', 'integration.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:contract']).to.equal('node tests/contract/integration.mjs');
expect(pkg?.scripts?.['test:ui']).to.equal('node tests/ui/smoke.mjs');
expect(pkg?.devDependencies?.solc).to.equal('0.8.24');
expect(pkg?.devDependencies?.web3).to.equal('^1.3.5');

const contractSmoke = runCmd('node', ['tests/contract/smoke.mjs'], uiDir);
expect(contractSmoke.status, contractSmoke.stderr || contractSmoke.stdout).to.equal(0);
Expand Down
Loading