Skip to content

Commit c3f5fe8

Browse files
authored
Merge pull request #35 from tokenhost/feat/generated-ui-smoke-tests
Generated app tests: add UI route + manifest live smoke CI
2 parents 92f869a + bb90694 commit c3f5fe8

3 files changed

Lines changed: 172 additions & 1 deletion

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,9 @@ Contract test env vars:
1414
- `TH_RPC_URL` (optional)
1515
- `TH_TEST_PRIVATE_KEY` (optional, defaults to anvil account #0 key)
1616

17+
UI smoke test env vars:
18+
- `TH_UI_BASE_URL` (optional; when set, `ui/smoke.mjs` performs live route and manifest checks)
19+
20+
When `TH_UI_BASE_URL` is not set, `ui/smoke.mjs` runs static scaffold checks only.
21+
1722
These tests are schema-driven and intended to be expanded further for app-specific assertions.

packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,66 @@ import path from 'node:path';
55
function mustExist(root, relPath) {
66
const p = path.join(root, relPath);
77
assert.equal(fs.existsSync(p), true, `Missing required generated UI file: ${relPath}`);
8+
return p;
9+
}
10+
11+
function loadGeneratedThs(root) {
12+
const thsPath = mustExist(root, 'src/generated/ths.ts');
13+
const source = fs.readFileSync(thsPath, 'utf-8');
14+
const match = source.match(/export const ths = ([\s\S]*?) as const;/);
15+
assert.ok(match, 'Unable to parse generated THS from src/generated/ths.ts');
16+
return JSON.parse(match[1]);
17+
}
18+
19+
async function fetchOrThrow(url) {
20+
const res = await fetch(url, { cache: 'no-store' });
21+
const text = await res.text();
22+
return { status: res.status, text, res };
23+
}
24+
25+
async function assertRoute200(baseUrl, route) {
26+
const u = `${baseUrl}${route}`;
27+
const out = await fetchOrThrow(u);
28+
assert.equal(out.status, 200, `Expected ${u} to return 200, got ${out.status}`);
29+
}
30+
31+
async function runLiveChecks(root, baseUrl, ths) {
32+
await assertRoute200(baseUrl, '/');
33+
34+
for (const collection of ths.collections || []) {
35+
const name = String(collection?.name ?? '');
36+
if (!name) continue;
37+
38+
await assertRoute200(baseUrl, `/${name}/`);
39+
await assertRoute200(baseUrl, `/${name}/new/`);
40+
await assertRoute200(baseUrl, `/${name}/view/?id=1`);
41+
42+
const canEdit = Array.isArray(collection?.updateRules?.mutable) && collection.updateRules.mutable.length > 0;
43+
if (canEdit) await assertRoute200(baseUrl, `/${name}/edit/?id=1`);
44+
45+
const canDelete = Boolean(collection?.deleteRules?.softDelete);
46+
if (canDelete) await assertRoute200(baseUrl, `/${name}/delete/?id=1`);
47+
}
48+
49+
const manifestRes = await fetchOrThrow(`${baseUrl}/.well-known/tokenhost/manifest.json`);
50+
assert.equal(manifestRes.status, 200, 'Manifest is missing from /.well-known/tokenhost/manifest.json');
51+
52+
const manifest = JSON.parse(manifestRes.text);
53+
const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : [];
54+
const primary = deployments.find((d) => d && d.role === 'primary') ?? deployments[0] ?? null;
55+
assert.ok(primary, 'Manifest has no deployments.');
56+
57+
const address = String(primary?.deploymentEntrypointAddress ?? '');
58+
assert.match(address, /^0x[0-9a-fA-F]{40}$/, 'Manifest deploymentEntrypointAddress is not a valid address.');
59+
assert.notEqual(
60+
address.toLowerCase(),
61+
'0x0000000000000000000000000000000000000000',
62+
'Manifest deploymentEntrypointAddress is 0x0. Run th deploy / preview auto-deploy first.'
63+
);
864
}
965

1066
const root = process.cwd();
67+
const ths = loadGeneratedThs(root);
1168

1269
for (const relPath of [
1370
'app/layout.tsx',
@@ -19,4 +76,11 @@ for (const relPath of [
1976
mustExist(root, relPath);
2077
}
2178

22-
console.log('PASS ui smoke scaffold');
79+
const baseUrlEnv = process.env.TH_UI_BASE_URL?.trim();
80+
if (baseUrlEnv) {
81+
const baseUrl = baseUrlEnv.replace(/\/+$/, '');
82+
await runLiveChecks(root, baseUrl, ths);
83+
console.log(`PASS ui smoke scaffold (live checks @ ${baseUrl})`);
84+
} else {
85+
console.log('PASS ui smoke scaffold (static checks only)');
86+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
function waitForOutput(proc, pattern, timeoutMs) {
29+
return new Promise((resolve, reject) => {
30+
const startedAt = Date.now();
31+
let combined = '';
32+
let done = false;
33+
34+
function cleanup() {
35+
if (done) return;
36+
done = true;
37+
clearInterval(timer);
38+
proc.stdout?.off('data', onData);
39+
proc.stderr?.off('data', onData);
40+
}
41+
42+
function onData(chunk) {
43+
combined += String(chunk ?? '');
44+
if (pattern.test(combined)) {
45+
cleanup();
46+
resolve(combined);
47+
}
48+
}
49+
50+
proc.stdout?.on('data', onData);
51+
proc.stderr?.on('data', onData);
52+
53+
const timer = setInterval(() => {
54+
if (Date.now() - startedAt < timeoutMs) return;
55+
cleanup();
56+
reject(new Error(`Timed out waiting for output match: ${pattern}\nOutput:\n${combined}`));
57+
}, 200);
58+
});
59+
}
60+
61+
describe('Generated app UI tests', function () {
62+
it('emits schema-aware UI smoke tests that pass against canonical job-board preview', async function () {
63+
this.timeout(240000);
64+
if (!hasAnvil()) this.skip();
65+
66+
const schemaPath = path.join(process.cwd(), 'apps', 'example', 'job-board.schema.json');
67+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-generated-ui-tests-'));
68+
const generateOut = path.join(rootDir, 'generated');
69+
const buildOut = path.join(rootDir, 'build');
70+
const uiDir = path.join(generateOut, 'ui');
71+
72+
const generateRes = runTh(['generate', schemaPath, '--out', generateOut, '--with-tests'], process.cwd());
73+
expect(generateRes.status, generateRes.stderr || generateRes.stdout).to.equal(0);
74+
75+
const installRes = runCmd('pnpm', ['install'], uiDir, { NEXT_TELEMETRY_DISABLED: '1' });
76+
expect(installRes.status, installRes.stderr || installRes.stdout).to.equal(0);
77+
78+
const buildRes = runTh(['build', schemaPath, '--out', buildOut], process.cwd());
79+
expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0);
80+
81+
const host = '127.0.0.1';
82+
const port = 46000 + Math.floor(Math.random() * 1000);
83+
const baseUrl = `http://${host}:${port}`;
84+
const preview = spawn(
85+
'node',
86+
[path.resolve('packages/cli/dist/index.js'), 'preview', buildOut, '--host', host, '--port', String(port)],
87+
{ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }
88+
);
89+
90+
try {
91+
await waitForOutput(preview, new RegExp(`${baseUrl}/`), 90000);
92+
const uiTestRes = runCmd('pnpm', ['run', 'test:ui'], uiDir, {
93+
NEXT_TELEMETRY_DISABLED: '1',
94+
TH_UI_BASE_URL: baseUrl
95+
});
96+
expect(uiTestRes.status, uiTestRes.stderr || uiTestRes.stdout).to.equal(0);
97+
expect(uiTestRes.stdout).to.include('PASS ui smoke scaffold (live checks @');
98+
} finally {
99+
preview.kill('SIGINT');
100+
}
101+
});
102+
});

0 commit comments

Comments
 (0)