Skip to content

Commit 0d7b964

Browse files
authored
Merge pull request #26 from tokenhost/feat/ci-abi-e2e
CI hardening: required ABI surface + local anvil integration gates
2 parents 5d77439 + 59029c5 commit 0d7b964

10 files changed

Lines changed: 370 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
- master
88

99
jobs:
10-
test:
10+
static:
1111
runs-on: ubuntu-latest
1212
timeout-minutes: 20
1313
steps:
@@ -48,3 +48,46 @@ jobs:
4848
- name: Audit (non-blocking)
4949
continue-on-error: true
5050
run: pnpm audit --audit-level high
51+
52+
integration-local:
53+
runs-on: ubuntu-latest
54+
timeout-minutes: 25
55+
steps:
56+
- name: Checkout
57+
uses: actions/checkout@v4
58+
59+
- name: Setup Node
60+
uses: actions/setup-node@v4
61+
with:
62+
node-version: "20"
63+
64+
- name: Setup pnpm
65+
uses: pnpm/action-setup@v4
66+
with:
67+
version: "10.12.1"
68+
69+
- name: Get pnpm store dir
70+
id: pnpm-store
71+
run: echo "store_path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
72+
73+
- name: Cache pnpm store
74+
uses: actions/cache@v4
75+
with:
76+
path: ${{ steps.pnpm-store.outputs.store_path }}
77+
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
78+
restore-keys: |
79+
pnpm-store-${{ runner.os }}-
80+
81+
- name: Install
82+
run: pnpm install --frozen-lockfile
83+
84+
- name: Install Foundry
85+
uses: foundry-rs/foundry-toolchain@v1
86+
with:
87+
version: stable
88+
89+
- name: Confirm Anvil
90+
run: anvil --version
91+
92+
- name: Local integration tests
93+
run: pnpm test:integration

Readme.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,25 @@ pnpm legacy:build
3737
# or
3838
pnpm legacy:build-run
3939
```
40+
41+
## Testing
42+
43+
Fast local suite (no local chain required):
44+
45+
```bash
46+
pnpm test
47+
pnpm typecheck
48+
```
49+
50+
Local integration suite (requires `anvil` on PATH):
51+
52+
```bash
53+
pnpm test:integration
54+
```
55+
56+
## CI
57+
58+
PRs run two required jobs:
59+
60+
- `static`: install + unit/CLI/template tests + typecheck
61+
- `integration-local`: install + Foundry + local Anvil integration tests (`th preview`/deploy/faucet paths)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"legacy:build-run": "sh build.sh && cd site && yarn run dev",
1616
"build": "pnpm -r --filter @tokenhost/* build",
1717
"typecheck": "pnpm -r --filter @tokenhost/* typecheck",
18-
"test": "pnpm build && mocha",
18+
"test": "pnpm build && mocha \"test/*.js\"",
19+
"test:integration": "pnpm build && mocha \"test/integration/*.js\"",
1920
"th": "tsx packages/cli/src/index.ts"
2021
},
2122
"dependencies": {

packages/templates/next-export-ui/app/[collection]/delete/ClientPage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
44
import { useRouter, useSearchParams } from 'next/navigation';
55

66
import { fetchAppAbi } from '../../../src/lib/abi';
7-
import { fnDelete, fnGet } from '../../../src/lib/app';
7+
import { assertAbiFunction, fnDelete, fnGet } from '../../../src/lib/app';
88
import { chainFromId } from '../../../src/lib/chains';
99
import { makePublicClient, makeWalletClient, requestWalletAddress } from '../../../src/lib/clients';
1010
import { shortAddress } from '../../../src/lib/format';
@@ -85,6 +85,7 @@ export default function DeleteRecordPage(props: { params: { collection: string }
8585
if (!publicClient || !abi || !appAddress || id === null) return;
8686
setError(null);
8787
try {
88+
assertAbiFunction(abi, fnGet(collectionName), collectionName);
8889
const r = await publicClient.readContract({
8990
address: appAddress,
9091
abi,
@@ -114,6 +115,7 @@ export default function DeleteRecordPage(props: { params: { collection: string }
114115
const account = await requestWalletAddress(chain);
115116
const walletClient = makeWalletClient(chain);
116117

118+
assertAbiFunction(abi, fnDelete(collectionName), collectionName);
117119
setStatus('Sending delete…');
118120
const hash = await walletClient.writeContract({
119121
address: appAddress,

packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
44
import { useRouter, useSearchParams } from 'next/navigation';
55

66
import { fetchAppAbi } from '../../../src/lib/abi';
7-
import { fnGet, fnUpdate } from '../../../src/lib/app';
7+
import { assertAbiFunction, fnGet, fnUpdate } from '../../../src/lib/app';
88
import { chainFromId } from '../../../src/lib/chains';
99
import { makePublicClient, makeWalletClient, requestWalletAddress } from '../../../src/lib/clients';
1010
import { formatNumeric, parseFieldValue } from '../../../src/lib/format';
@@ -98,6 +98,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
9898
if (!publicClient || !abi || !appAddress || id === null) return;
9999
setError(null);
100100
try {
101+
assertAbiFunction(abi, fnGet(collectionName), collectionName);
101102
const r = await publicClient.readContract({
102103
address: appAddress,
103104
abi,
@@ -154,6 +155,7 @@ export default function EditRecordPage(props: { params: { collection: string } }
154155
args.push(typeof v === 'bigint' ? v : BigInt(String(v ?? '0')));
155156
}
156157

158+
assertAbiFunction(abi, fnUpdate(collectionName), collectionName);
157159
setStatus('Sending transaction…');
158160
const hash = await walletClient.writeContract({
159161
address: appAddress,

packages/templates/next-export-ui/app/[collection]/new/ClientPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
44
import { useRouter, useSearchParams } from 'next/navigation';
55

66
import { fetchAppAbi } from '../../../src/lib/abi';
7-
import { fnCreate } from '../../../src/lib/app';
7+
import { assertAbiFunction, fnCreate } from '../../../src/lib/app';
88
import { chainFromId } from '../../../src/lib/chains';
99
import { makePublicClient, makeWalletClient, requestWalletAddress } from '../../../src/lib/clients';
1010
import { formatWei, parseFieldValue } from '../../../src/lib/format';
@@ -124,6 +124,7 @@ export default function CreateRecordPage(props: { params: { collection: string }
124124
const account = await requestWalletAddress(chain);
125125
const walletClient = makeWalletClient(chain);
126126

127+
assertAbiFunction(abi, fnCreate(collectionName), collectionName);
127128
const args = fields.map((f) => parseFieldValue(form[f.name] ?? '', f.type, (f as any).decimals));
128129

129130
setStatus('Sending transaction…');

packages/templates/next-export-ui/app/[collection]/view/ClientPage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
44
import { useRouter, useSearchParams } from 'next/navigation';
55

66
import { fetchAppAbi } from '../../../src/lib/abi';
7-
import { collectionId, fnGet, fnTransfer } from '../../../src/lib/app';
7+
import { assertAbiFunction, collectionId, fnGet, fnTransfer } from '../../../src/lib/app';
88
import { chainFromId } from '../../../src/lib/chains';
99
import { makePublicClient, makeWalletClient, requestWalletAddress } from '../../../src/lib/clients';
1010
import { formatNumeric, shortAddress } from '../../../src/lib/format';
@@ -94,6 +94,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
9494

9595
setError(null);
9696
try {
97+
assertAbiFunction(abi, fnGet(collectionName), collectionName);
9798
const r = await publicClient.readContract({
9899
address: appAddress,
99100
abi,
@@ -147,6 +148,7 @@ export default function ViewRecordPage(props: { params: { collection: string } }
147148
const account = await requestWalletAddress(chain);
148149
const walletClient = makeWalletClient(chain);
149150

151+
assertAbiFunction(abi, fnTransfer(collectionName), collectionName);
150152
setTxStatus('Sending transfer…');
151153
const hash = await walletClient.writeContract({
152154
address: appAddress,

packages/templates/next-export-ui/src/lib/app.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,38 @@ export function fnTransfer(collectionName: string): string {
3636
return `transfer${collectionName}`;
3737
}
3838

39+
function abiFnSignature(entry: any): string | null {
40+
if (!entry || entry.type !== 'function' || typeof entry.name !== 'string') return null;
41+
const inputs = Array.isArray(entry.inputs) ? entry.inputs : [];
42+
const types = inputs.map((i) => String(i?.type ?? '')).join(',');
43+
return `${entry.name}(${types})`;
44+
}
45+
46+
export function hasAbiFunction(abi: any[], nameOrSignature: string): boolean {
47+
if (!Array.isArray(abi)) return false;
48+
for (const entry of abi) {
49+
const sig = abiFnSignature(entry);
50+
if (!sig) continue;
51+
if (sig === nameOrSignature) return true;
52+
if (!nameOrSignature.includes('(') && entry.name === nameOrSignature) return true;
53+
}
54+
return false;
55+
}
56+
57+
export function assertAbiFunction(abi: any[], nameOrSignature: string, collectionName: string): void {
58+
if (hasAbiFunction(abi, nameOrSignature)) return;
59+
const known = (Array.isArray(abi) ? abi : [])
60+
.map((entry) => abiFnSignature(entry))
61+
.filter(Boolean)
62+
.slice(0, 30)
63+
.join(', ');
64+
throw new Error(
65+
`ABI mismatch for collection "${collectionName}". Missing function "${nameOrSignature}". ` +
66+
`This usually means the route collection key does not match the schema collection name or ABI is stale. ` +
67+
`Known ABI functions: ${known}`
68+
);
69+
}
70+
3971
export async function appMulticall(args: {
4072
publicClient: any;
4173
abi: any;
@@ -60,6 +92,9 @@ export async function listRecords(args: {
6092
limit: number;
6193
}): Promise<{ ids: bigint[]; records: any[] }>
6294
{
95+
assertAbiFunction(args.abi, fnListIds(args.collectionName), args.collectionName);
96+
assertAbiFunction(args.abi, fnGet(args.collectionName), args.collectionName);
97+
6398
const ids = (await args.publicClient.readContract({
6499
address: args.address,
65100
abi: args.abi,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 writeJson(filePath, value) {
8+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
9+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
10+
}
11+
12+
function runTh(args, cwd) {
13+
return spawnSync('node', [path.resolve('packages/cli/dist/index.js'), ...args], {
14+
cwd,
15+
encoding: 'utf-8'
16+
});
17+
}
18+
19+
function hasAnvil() {
20+
const res = spawnSync('anvil', ['--version'], { encoding: 'utf-8' });
21+
if (res.error && res.error.code === 'ENOENT') return false;
22+
return res.status === 0;
23+
}
24+
25+
function waitForOutput(proc, pattern, timeoutMs) {
26+
return new Promise((resolve, reject) => {
27+
const startedAt = Date.now();
28+
let combined = '';
29+
let done = false;
30+
31+
function cleanup() {
32+
if (done) return;
33+
done = true;
34+
clearInterval(timer);
35+
proc.stdout?.off('data', onData);
36+
proc.stderr?.off('data', onData);
37+
}
38+
39+
function onData(chunk) {
40+
combined += String(chunk ?? '');
41+
if (pattern.test(combined)) {
42+
cleanup();
43+
resolve(combined);
44+
}
45+
}
46+
47+
proc.stdout?.on('data', onData);
48+
proc.stderr?.on('data', onData);
49+
50+
const timer = setInterval(() => {
51+
if (Date.now() - startedAt < timeoutMs) return;
52+
cleanup();
53+
reject(new Error(`Timed out waiting for output match: ${pattern}\nOutput:\n${combined}`));
54+
}, 200);
55+
});
56+
}
57+
58+
async function requestJson(url, init) {
59+
const res = await fetch(url, init);
60+
const text = await res.text();
61+
let json = null;
62+
try {
63+
json = text ? JSON.parse(text) : null;
64+
} catch {
65+
json = null;
66+
}
67+
return { status: res.status, json, text };
68+
}
69+
70+
function schemaForIntegration() {
71+
return {
72+
thsVersion: '2025-12',
73+
schemaVersion: '0.0.1',
74+
app: {
75+
name: 'Integration Test App',
76+
slug: 'integration-test-app',
77+
features: { uploads: false, onChainIndexing: true }
78+
},
79+
collections: [
80+
{
81+
name: 'Candidate',
82+
fields: [{ name: 'name', type: 'string', required: true }],
83+
createRules: { required: ['name'], access: 'public' },
84+
visibilityRules: { gets: ['name'], access: 'public' },
85+
updateRules: { mutable: ['name'], access: 'owner' },
86+
deleteRules: { softDelete: true, access: 'owner' },
87+
transferRules: { access: 'owner' },
88+
indexes: { unique: [], index: [] }
89+
}
90+
]
91+
};
92+
}
93+
94+
describe('CLI local integration (anvil + preview + faucet)', function () {
95+
it('builds, auto-deploys in preview, serves manifest, and funds wallet via faucet endpoint', async function () {
96+
this.timeout(180000);
97+
98+
if (!hasAnvil()) this.skip();
99+
100+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-integration-'));
101+
const schemaPath = path.join(dir, 'schema.json');
102+
const outDir = path.join(dir, 'out');
103+
const schema = schemaForIntegration();
104+
writeJson(schemaPath, schema);
105+
106+
const buildRes = runTh(['build', schemaPath, '--out', outDir], process.cwd());
107+
expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0);
108+
109+
const port = 42000 + Math.floor(Math.random() * 2000);
110+
const host = '127.0.0.1';
111+
const baseUrl = `http://${host}:${port}`;
112+
113+
const preview = spawn(
114+
'node',
115+
[path.resolve('packages/cli/dist/index.js'), 'preview', outDir, '--host', host, '--port', String(port)],
116+
{ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }
117+
);
118+
119+
try {
120+
const previewReadyPattern = new RegExp(`http://${host}:${port}/`);
121+
await waitForOutput(preview, previewReadyPattern, 60000);
122+
123+
const home = await requestJson(`${baseUrl}/`);
124+
expect(home.status).to.equal(200);
125+
126+
const manifestRes = await requestJson(`${baseUrl}/.well-known/tokenhost/manifest.json`);
127+
expect(manifestRes.status).to.equal(200);
128+
expect(manifestRes.json?.deployments?.[0]?.deploymentEntrypointAddress).to.match(/^0x[0-9a-fA-F]{40}$/);
129+
expect(manifestRes.json?.deployments?.[0]?.deploymentEntrypointAddress.toLowerCase()).to.not.equal(
130+
'0x0000000000000000000000000000000000000000'
131+
);
132+
133+
const faucetStatus = await requestJson(`${baseUrl}/__tokenhost/faucet`);
134+
expect(faucetStatus.status).to.equal(200);
135+
expect(faucetStatus.json?.ok).to.equal(true);
136+
expect(faucetStatus.json?.enabled).to.equal(true);
137+
expect(faucetStatus.json?.chainId).to.equal(31337);
138+
139+
const addr = '0x1111111111111111111111111111111111111111';
140+
const faucetFund = await requestJson(`${baseUrl}/__tokenhost/faucet`, {
141+
method: 'POST',
142+
headers: { 'content-type': 'application/json' },
143+
body: JSON.stringify({ address: addr })
144+
});
145+
expect(faucetFund.status).to.equal(200);
146+
expect(faucetFund.json?.ok).to.equal(true);
147+
expect(typeof faucetFund.json?.newBalanceWei).to.equal('string');
148+
} finally {
149+
preview.kill('SIGINT');
150+
}
151+
});
152+
});

0 commit comments

Comments
 (0)