diff --git a/packages/templates/next-export-ui/test-scaffold/tests/README.md b/packages/templates/next-export-ui/test-scaffold/tests/README.md index b48e984..38d0f15 100644 --- a/packages/templates/next-export-ui/test-scaffold/tests/README.md +++ b/packages/templates/next-export-ui/test-scaffold/tests/README.md @@ -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. diff --git a/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs b/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs index 365053c..6e4fd34 100644 --- a/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs +++ b/packages/templates/next-export-ui/test-scaffold/tests/ui/smoke.mjs @@ -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', @@ -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)'); +} diff --git a/test/integration/testGeneratedAppUiTests.js b/test/integration/testGeneratedAppUiTests.js new file mode 100644 index 0000000..1ee0574 --- /dev/null +++ b/test/integration/testGeneratedAppUiTests.js @@ -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'); + } + }); +});