Skip to content

Commit 71a3a9d

Browse files
committed
Generated tests: emit schema-driven contract integration harness
Resolve issue #29 by upgrading --with-tests output to include schema-driven on-chain contract integration tests and validating emitted tests against canonical job-board output.
1 parent 39a3e38 commit 71a3a9d

5 files changed

Lines changed: 317 additions & 3 deletions

File tree

packages/cli/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,13 @@ function addGeneratedUiTestScaffold(uiDir: string, templateDir: string) {
120120
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
121121
const scripts = { ...(pkg.scripts || {}) };
122122
scripts.test = scripts.test || 'pnpm run test:contract && pnpm run test:ui';
123-
scripts['test:contract'] = scripts['test:contract'] || 'node tests/contract/smoke.mjs';
123+
scripts['test:contract'] = scripts['test:contract'] || 'node tests/contract/integration.mjs';
124124
scripts['test:ui'] = scripts['test:ui'] || 'node tests/ui/smoke.mjs';
125125
pkg.scripts = scripts;
126+
const devDependencies = { ...(pkg.devDependencies || {}) };
127+
devDependencies.solc = devDependencies.solc || '0.8.24';
128+
devDependencies.web3 = devDependencies.web3 || '^1.3.5';
129+
pkg.devDependencies = devDependencies;
126130
fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
127131
}
128132

packages/templates/next-export-ui/test-scaffold/tests/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ Generated app test scaffold
22

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

5+
- `contract/integration.mjs` runs schema-driven contract behavior tests against local anvil.
56
- `contract/smoke.mjs` validates baseline generated app contract test preconditions.
67
- `ui/smoke.mjs` validates baseline generated UI route/component preconditions.
78

8-
These are starter tests and are intended to be expanded with schema-specific assertions.
9+
Contract integration prerequisites:
10+
- local anvil RPC (default `http://127.0.0.1:8545`)
11+
- generated `../contracts/App.sol` and `../schema.json`
12+
13+
Contract test env vars:
14+
- `TH_RPC_URL` (optional)
15+
- `TH_TEST_PRIVATE_KEY` (optional, defaults to anvil account #0 key)
16+
17+
These tests are schema-driven and intended to be expanded further for app-specific assertions.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { createRequire } from 'node:module';
5+
6+
import Web3 from 'web3';
7+
8+
const require = createRequire(import.meta.url);
9+
const solc = require('solc');
10+
11+
const DEFAULT_RPC_URL = 'http://127.0.0.1:8545';
12+
const DEFAULT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
13+
14+
function mustReadJson(filePath) {
15+
if (!fs.existsSync(filePath)) throw new Error(`Missing file: ${filePath}`);
16+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
17+
}
18+
19+
function mustReadText(filePath) {
20+
if (!fs.existsSync(filePath)) throw new Error(`Missing file: ${filePath}`);
21+
return fs.readFileSync(filePath, 'utf-8');
22+
}
23+
24+
function compileApp(source) {
25+
const input = {
26+
language: 'Solidity',
27+
sources: {
28+
'App.sol': { content: source }
29+
},
30+
settings: {
31+
optimizer: { enabled: true, runs: 200 },
32+
outputSelection: {
33+
'*': {
34+
'*': ['abi', 'evm.bytecode.object']
35+
}
36+
}
37+
}
38+
};
39+
40+
const output = JSON.parse(solc.compile(JSON.stringify(input)));
41+
const errors = (output.errors || []).filter((e) => e.severity === 'error');
42+
if (errors.length > 0) {
43+
throw new Error(errors.map((e) => e.formattedMessage || e.message).join('\n'));
44+
}
45+
46+
const app = output?.contracts?.['App.sol']?.App;
47+
if (!app?.abi || !app?.evm?.bytecode?.object) {
48+
throw new Error('Failed to compile App.sol (missing abi/bytecode).');
49+
}
50+
return { abi: app.abi, bytecode: `0x${app.evm.bytecode.object}` };
51+
}
52+
53+
function sampleValue(field, idx, forUpdate, accountAddress) {
54+
const suffix = `${forUpdate ? 'u' : 'c'}-${idx}`;
55+
switch (field.type) {
56+
case 'string':
57+
case 'image':
58+
return `${field.name}-${suffix}`;
59+
case 'uint256':
60+
case 'reference':
61+
case 'decimal':
62+
return String(1000 + idx);
63+
case 'int256':
64+
return String(-100 - idx);
65+
case 'bool':
66+
return idx % 2 === 0;
67+
case 'address':
68+
case 'externalReference':
69+
return accountAddress;
70+
case 'bytes32':
71+
return `0x${'ab'.repeat(32)}`;
72+
default:
73+
throw new Error(`Unsupported field type for generated tests: ${field.type}`);
74+
}
75+
}
76+
77+
async function mustFail(promiseFactory, expectedHint) {
78+
let failed = false;
79+
try {
80+
await promiseFactory();
81+
} catch (e) {
82+
failed = true;
83+
if (expectedHint) {
84+
const msg = String(e?.message ?? e);
85+
assert.match(msg, expectedHint, `Expected error hint ${expectedHint}, got: ${msg}`);
86+
}
87+
}
88+
assert.equal(failed, true, 'Expected operation to fail but it succeeded.');
89+
}
90+
91+
async function main() {
92+
const root = process.cwd();
93+
const parent = path.resolve(root, '..');
94+
const schemaPath = path.join(parent, 'schema.json');
95+
const appSolPath = path.join(parent, 'contracts', 'App.sol');
96+
97+
const schema = mustReadJson(schemaPath);
98+
const appSol = mustReadText(appSolPath);
99+
const { abi, bytecode } = compileApp(appSol);
100+
101+
const rpcUrl = process.env.TH_RPC_URL || DEFAULT_RPC_URL;
102+
const privateKey = process.env.TH_TEST_PRIVATE_KEY || DEFAULT_PRIVATE_KEY;
103+
const web3 = new Web3(rpcUrl);
104+
105+
const listening = await web3.eth.net.isListening().catch(() => false);
106+
if (!listening) {
107+
throw new Error(`RPC is not reachable at ${rpcUrl}. Start anvil and retry.`);
108+
}
109+
110+
const account = web3.eth.accounts.privateKeyToAccount(privateKey);
111+
web3.eth.accounts.wallet.add(account);
112+
web3.eth.defaultAccount = account.address;
113+
114+
const anyPaidCreates = (schema.collections || []).some((c) => Boolean(c?.createRules?.payment));
115+
const deployArgs = anyPaidCreates ? [account.address, account.address] : [];
116+
117+
const app = await new web3.eth.Contract(abi)
118+
.deploy({ data: bytecode, arguments: deployArgs })
119+
.send({ from: account.address, gas: 8_000_000 });
120+
121+
for (const collection of schema.collections || []) {
122+
const name = String(collection.name);
123+
const fields = Array.isArray(collection.fields) ? collection.fields : [];
124+
const mutable = Array.isArray(collection?.updateRules?.mutable) ? collection.updateRules.mutable : [];
125+
const softDelete = Boolean(collection?.deleteRules?.softDelete);
126+
const hasTransfer = Boolean(collection?.transferRules);
127+
const hasPayment = Boolean(collection?.createRules?.payment?.amountWei);
128+
const optimistic = Boolean(collection?.updateRules?.optimisticConcurrency);
129+
130+
const createFn = `create${name}`;
131+
const listFn = `listIds${name}`;
132+
const getFn = `get${name}(uint256)`;
133+
const getWithDeletedFn = `get${name}(uint256,bool)`;
134+
const updateFn = `update${name}`;
135+
const deleteFn = `delete${name}`;
136+
const transferFn = `transfer${name}`;
137+
138+
const createArgs = fields.map((f, idx) => sampleValue(f, idx, false, account.address));
139+
140+
if (hasPayment) {
141+
await mustFail(() =>
142+
app.methods[createFn](...createArgs).send({ from: account.address, gas: 3_000_000 })
143+
);
144+
145+
await app.methods[createFn](...createArgs).send({
146+
from: account.address,
147+
gas: 3_000_000,
148+
value: String(collection.createRules.payment.amountWei)
149+
});
150+
} else {
151+
await app.methods[createFn](...createArgs).send({ from: account.address, gas: 3_000_000 });
152+
}
153+
154+
const ids = await app.methods[listFn](0, 20, false).call();
155+
assert.equal(Array.isArray(ids), true, `${listFn} must return an array`);
156+
assert.equal(ids.length > 0, true, `${listFn} must include created record`);
157+
const id = Number(ids[0]);
158+
159+
const current = await app.methods[getFn](id).call();
160+
assert.ok(current, `${getFn} should return a record`);
161+
162+
if (hasTransfer) {
163+
const accounts = await web3.eth.getAccounts();
164+
const to = accounts[1] || account.address;
165+
await app.methods[transferFn](id, to).send({ from: account.address, gas: 3_000_000 });
166+
const afterTransfer = await app.methods[getFn](id).call();
167+
assert.equal(
168+
String(afterTransfer.owner || '').toLowerCase(),
169+
String(to).toLowerCase(),
170+
`${transferFn} should update owner`
171+
);
172+
}
173+
174+
if (mutable.length > 0) {
175+
const updateArgs = [id];
176+
for (const mutableFieldName of mutable) {
177+
const field = fields.find((f) => f?.name === mutableFieldName);
178+
if (!field) continue;
179+
updateArgs.push(sampleValue(field, 777, true, account.address));
180+
}
181+
if (optimistic) updateArgs.push('0');
182+
183+
await app.methods[updateFn](...updateArgs).send({ from: account.address, gas: 3_000_000 });
184+
const afterUpdate = await app.methods[getFn](id).call();
185+
const firstMutable = mutable[0];
186+
const firstMutableField = fields.find((f) => f.name === firstMutable);
187+
if (firstMutable && firstMutable in afterUpdate && firstMutableField) {
188+
assert.equal(
189+
String(afterUpdate[firstMutable]),
190+
String(sampleValue(firstMutableField, 777, true, account.address)),
191+
`${updateFn} should update mutable field ${firstMutable}`
192+
);
193+
}
194+
}
195+
196+
if (softDelete) {
197+
await app.methods[deleteFn](id).send({ from: account.address, gas: 3_000_000 });
198+
const deletedRecord = await app.methods[getWithDeletedFn](id, true).call();
199+
assert.equal(Boolean(deletedRecord.isDeleted), true, `${deleteFn} should mark isDeleted`);
200+
201+
const activeIds = await app.methods[listFn](0, 20, false).call();
202+
const hasId = (activeIds || []).map((x) => String(x)).includes(String(id));
203+
assert.equal(hasId, false, `${listFn} should exclude soft-deleted record by default`);
204+
}
205+
}
206+
207+
console.log('PASS contract integration scaffold');
208+
}
209+
210+
main().catch((e) => {
211+
console.error(String(e?.stack || e?.message || e));
212+
process.exit(1);
213+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { expect } from 'chai';
2+
import fs from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { spawn, spawnSync } from 'child_process';
6+
7+
function runTh(args, cwd) {
8+
return spawnSync('node', [path.resolve('packages/cli/dist/index.js'), ...args], {
9+
cwd,
10+
encoding: 'utf-8'
11+
});
12+
}
13+
14+
function runCmd(cmd, args, cwd, extraEnv = {}) {
15+
return spawnSync(cmd, args, {
16+
cwd,
17+
encoding: 'utf-8',
18+
env: { ...process.env, ...extraEnv }
19+
});
20+
}
21+
22+
function hasAnvil() {
23+
const res = spawnSync('anvil', ['--version'], { encoding: 'utf-8' });
24+
if (res.error && res.error.code === 'ENOENT') return false;
25+
return res.status === 0;
26+
}
27+
28+
async function tryGetChainIdHex(rpcUrl) {
29+
const res = await fetch(rpcUrl, {
30+
method: 'POST',
31+
headers: { 'content-type': 'application/json' },
32+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] })
33+
});
34+
if (!res.ok) return null;
35+
const json = await res.json();
36+
return typeof json?.result === 'string' ? json.result : null;
37+
}
38+
39+
async function waitForRpc(rpcUrl, timeoutMs) {
40+
const startedAt = Date.now();
41+
while (Date.now() - startedAt < timeoutMs) {
42+
try {
43+
const hex = await tryGetChainIdHex(rpcUrl);
44+
if (hex) return hex;
45+
} catch {
46+
// continue polling
47+
}
48+
await new Promise((r) => setTimeout(r, 200));
49+
}
50+
throw new Error(`Timed out waiting for RPC at ${rpcUrl}`);
51+
}
52+
53+
describe('Generated app contract tests', function () {
54+
it('emits and runs schema-driven contract integration tests for canonical job-board output', async function () {
55+
this.timeout(240000);
56+
if (!hasAnvil()) this.skip();
57+
58+
const schemaPath = path.join(process.cwd(), 'apps', 'example', 'job-board.schema.json');
59+
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-generated-app-tests-'));
60+
const uiDir = path.join(outDir, 'ui');
61+
62+
const generateRes = runTh(['generate', schemaPath, '--out', outDir, '--with-tests'], process.cwd());
63+
expect(generateRes.status, generateRes.stderr || generateRes.stdout).to.equal(0);
64+
65+
const installRes = runCmd('pnpm', ['install'], uiDir, { NEXT_TELEMETRY_DISABLED: '1' });
66+
expect(installRes.status, installRes.stderr || installRes.stdout).to.equal(0);
67+
68+
const port = 45000 + Math.floor(Math.random() * 1000);
69+
const rpcUrl = `http://127.0.0.1:${port}`;
70+
const anvil = spawn('anvil', ['--host', '127.0.0.1', '--port', String(port), '--chain-id', '31337'], {
71+
stdio: ['ignore', 'pipe', 'pipe']
72+
});
73+
74+
try {
75+
await waitForRpc(rpcUrl, 15000);
76+
const testRes = runCmd('node', ['tests/contract/integration.mjs'], uiDir, {
77+
TH_RPC_URL: rpcUrl
78+
});
79+
expect(testRes.status, testRes.stderr || testRes.stdout).to.equal(0);
80+
expect(testRes.stdout).to.include('PASS contract integration scaffold');
81+
} finally {
82+
anvil.kill('SIGINT');
83+
}
84+
});
85+
});

test/testCliGenerateUi.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,15 @@ describe('th generate (UI template)', function () {
123123

124124
const uiDir = path.join(outDir, 'ui');
125125
expect(fs.existsSync(path.join(uiDir, 'tests', 'contract', 'smoke.mjs'))).to.equal(true);
126+
expect(fs.existsSync(path.join(uiDir, 'tests', 'contract', 'integration.mjs'))).to.equal(true);
126127
expect(fs.existsSync(path.join(uiDir, 'tests', 'ui', 'smoke.mjs'))).to.equal(true);
127128

128129
const pkg = JSON.parse(fs.readFileSync(path.join(uiDir, 'package.json'), 'utf-8'));
129130
expect(pkg?.scripts?.test).to.equal('pnpm run test:contract && pnpm run test:ui');
130-
expect(pkg?.scripts?.['test:contract']).to.equal('node tests/contract/smoke.mjs');
131+
expect(pkg?.scripts?.['test:contract']).to.equal('node tests/contract/integration.mjs');
131132
expect(pkg?.scripts?.['test:ui']).to.equal('node tests/ui/smoke.mjs');
133+
expect(pkg?.devDependencies?.solc).to.equal('0.8.24');
134+
expect(pkg?.devDependencies?.web3).to.equal('^1.3.5');
132135

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

0 commit comments

Comments
 (0)