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
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@ Contract test env vars:
- `TH_RPC_URL` (optional)
- `TH_TEST_PRIVATE_KEY` (optional, defaults to anvil account #0 key)

UI smoke test env vars:
- `TH_UI_BASE_URL` (optional; when set, `ui/smoke.mjs` performs live route and manifest checks)

When `TH_UI_BASE_URL` is not set, `ui/smoke.mjs` runs static scaffold checks only.

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
Expand Up @@ -5,9 +5,66 @@ 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}`);
return p;
}

function loadGeneratedThs(root) {
const thsPath = mustExist(root, 'src/generated/ths.ts');
const source = fs.readFileSync(thsPath, 'utf-8');
const match = source.match(/export const ths = ([\s\S]*?) as const;/);
assert.ok(match, 'Unable to parse generated THS from src/generated/ths.ts');
return JSON.parse(match[1]);
}

async function fetchOrThrow(url) {
const res = await fetch(url, { cache: 'no-store' });
const text = await res.text();
return { status: res.status, text, res };
}

async function assertRoute200(baseUrl, route) {
const u = `${baseUrl}${route}`;
const out = await fetchOrThrow(u);
assert.equal(out.status, 200, `Expected ${u} to return 200, got ${out.status}`);
}

async function runLiveChecks(root, baseUrl, ths) {
await assertRoute200(baseUrl, '/');

for (const collection of ths.collections || []) {
const name = String(collection?.name ?? '');
if (!name) continue;

await assertRoute200(baseUrl, `/${name}/`);
await assertRoute200(baseUrl, `/${name}/new/`);
await assertRoute200(baseUrl, `/${name}/view/?id=1`);

const canEdit = Array.isArray(collection?.updateRules?.mutable) && collection.updateRules.mutable.length > 0;
if (canEdit) await assertRoute200(baseUrl, `/${name}/edit/?id=1`);

const canDelete = Boolean(collection?.deleteRules?.softDelete);
if (canDelete) await assertRoute200(baseUrl, `/${name}/delete/?id=1`);
}

const manifestRes = await fetchOrThrow(`${baseUrl}/.well-known/tokenhost/manifest.json`);
assert.equal(manifestRes.status, 200, 'Manifest is missing from /.well-known/tokenhost/manifest.json');

const manifest = JSON.parse(manifestRes.text);
const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : [];
const primary = deployments.find((d) => d && d.role === 'primary') ?? deployments[0] ?? null;
assert.ok(primary, 'Manifest has no deployments.');

const address = String(primary?.deploymentEntrypointAddress ?? '');
assert.match(address, /^0x[0-9a-fA-F]{40}$/, 'Manifest deploymentEntrypointAddress is not a valid address.');
assert.notEqual(
address.toLowerCase(),
'0x0000000000000000000000000000000000000000',
'Manifest deploymentEntrypointAddress is 0x0. Run th deploy / preview auto-deploy first.'
);
}

const root = process.cwd();
const ths = loadGeneratedThs(root);

for (const relPath of [
'app/layout.tsx',
Expand All @@ -19,4 +76,11 @@ for (const relPath of [
mustExist(root, relPath);
}

console.log('PASS ui smoke scaffold');
const baseUrlEnv = process.env.TH_UI_BASE_URL?.trim();
if (baseUrlEnv) {
const baseUrl = baseUrlEnv.replace(/\/+$/, '');
await runLiveChecks(root, baseUrl, ths);
console.log(`PASS ui smoke scaffold (live checks @ ${baseUrl})`);
} else {
console.log('PASS ui smoke scaffold (static checks only)');
}
102 changes: 102 additions & 0 deletions test/integration/testGeneratedAppUiTests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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;
}

function waitForOutput(proc, pattern, timeoutMs) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
let combined = '';
let done = false;

function cleanup() {
if (done) return;
done = true;
clearInterval(timer);
proc.stdout?.off('data', onData);
proc.stderr?.off('data', onData);
}

function onData(chunk) {
combined += String(chunk ?? '');
if (pattern.test(combined)) {
cleanup();
resolve(combined);
}
}

proc.stdout?.on('data', onData);
proc.stderr?.on('data', onData);

const timer = setInterval(() => {
if (Date.now() - startedAt < timeoutMs) return;
cleanup();
reject(new Error(`Timed out waiting for output match: ${pattern}\nOutput:\n${combined}`));
}, 200);
});
}

describe('Generated app UI tests', function () {
it('emits schema-aware UI smoke tests that pass against canonical job-board preview', async function () {
this.timeout(240000);
if (!hasAnvil()) this.skip();

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

const generateRes = runTh(['generate', schemaPath, '--out', generateOut, '--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 buildRes = runTh(['build', schemaPath, '--out', buildOut], process.cwd());
expect(buildRes.status, buildRes.stderr || buildRes.stdout).to.equal(0);

const host = '127.0.0.1';
const port = 46000 + Math.floor(Math.random() * 1000);
const baseUrl = `http://${host}:${port}`;
const preview = spawn(
'node',
[path.resolve('packages/cli/dist/index.js'), 'preview', buildOut, '--host', host, '--port', String(port)],
{ cwd: process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] }
);

try {
await waitForOutput(preview, new RegExp(`${baseUrl}/`), 90000);
const uiTestRes = runCmd('pnpm', ['run', 'test:ui'], uiDir, {
NEXT_TELEMETRY_DISABLED: '1',
TH_UI_BASE_URL: baseUrl
});
expect(uiTestRes.status, uiTestRes.stderr || uiTestRes.stdout).to.equal(0);
expect(uiTestRes.stdout).to.include('PASS ui smoke scaffold (live checks @');
} finally {
preview.kill('SIGINT');
}
});
});
Loading