From bca14232cd8290941690d9069392b24fbcce26ea Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 5 Feb 2026 10:05:57 -1000 Subject: [PATCH 1/2] 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. --- packages/cli/src/index.ts | 6 +- .../test-scaffold/tests/README.md | 11 +- .../tests/contract/integration.mjs | 213 ++++++++++++++++++ .../testGeneratedAppContractTests.js | 85 +++++++ test/testCliGenerateUi.js | 5 +- 5 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 packages/templates/next-export-ui/test-scaffold/tests/contract/integration.mjs create mode 100644 test/integration/testGeneratedAppContractTests.js diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 36f716f..a5f4e5b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -120,9 +120,13 @@ function addGeneratedUiTestScaffold(uiDir: string, templateDir: string) { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); const scripts = { ...(pkg.scripts || {}) }; scripts.test = scripts.test || 'pnpm run test:contract && pnpm run test:ui'; - scripts['test:contract'] = scripts['test:contract'] || 'node tests/contract/smoke.mjs'; + scripts['test:contract'] = scripts['test:contract'] || 'node tests/contract/integration.mjs'; scripts['test:ui'] = scripts['test:ui'] || 'node tests/ui/smoke.mjs'; pkg.scripts = scripts; + const devDependencies = { ...(pkg.devDependencies || {}) }; + devDependencies.solc = devDependencies.solc || '0.8.24'; + devDependencies.web3 = devDependencies.web3 || '^1.3.5'; + pkg.devDependencies = devDependencies; fs.writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n'); } 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 1c30be8..b48e984 100644 --- a/packages/templates/next-export-ui/test-scaffold/tests/README.md +++ b/packages/templates/next-export-ui/test-scaffold/tests/README.md @@ -2,7 +2,16 @@ Generated app test scaffold This directory is emitted by `th generate --with-tests`. +- `contract/integration.mjs` runs schema-driven contract behavior tests against local anvil. - `contract/smoke.mjs` validates baseline generated app contract test preconditions. - `ui/smoke.mjs` validates baseline generated UI route/component preconditions. -These are starter tests and are intended to be expanded with schema-specific assertions. +Contract integration prerequisites: +- local anvil RPC (default `http://127.0.0.1:8545`) +- generated `../contracts/App.sol` and `../schema.json` + +Contract test env vars: +- `TH_RPC_URL` (optional) +- `TH_TEST_PRIVATE_KEY` (optional, defaults to anvil account #0 key) + +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/contract/integration.mjs b/packages/templates/next-export-ui/test-scaffold/tests/contract/integration.mjs new file mode 100644 index 0000000..afb09d1 --- /dev/null +++ b/packages/templates/next-export-ui/test-scaffold/tests/contract/integration.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +import Web3 from 'web3'; + +const require = createRequire(import.meta.url); +const solc = require('solc'); + +const DEFAULT_RPC_URL = 'http://127.0.0.1:8545'; +const DEFAULT_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; + +function mustReadJson(filePath) { + if (!fs.existsSync(filePath)) throw new Error(`Missing file: ${filePath}`); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +function mustReadText(filePath) { + if (!fs.existsSync(filePath)) throw new Error(`Missing file: ${filePath}`); + return fs.readFileSync(filePath, 'utf-8'); +} + +function compileApp(source) { + const input = { + language: 'Solidity', + sources: { + 'App.sol': { content: source } + }, + settings: { + optimizer: { enabled: true, runs: 200 }, + outputSelection: { + '*': { + '*': ['abi', 'evm.bytecode.object'] + } + } + } + }; + + const output = JSON.parse(solc.compile(JSON.stringify(input))); + const errors = (output.errors || []).filter((e) => e.severity === 'error'); + if (errors.length > 0) { + throw new Error(errors.map((e) => e.formattedMessage || e.message).join('\n')); + } + + const app = output?.contracts?.['App.sol']?.App; + if (!app?.abi || !app?.evm?.bytecode?.object) { + throw new Error('Failed to compile App.sol (missing abi/bytecode).'); + } + return { abi: app.abi, bytecode: `0x${app.evm.bytecode.object}` }; +} + +function sampleValue(field, idx, forUpdate, accountAddress) { + const suffix = `${forUpdate ? 'u' : 'c'}-${idx}`; + switch (field.type) { + case 'string': + case 'image': + return `${field.name}-${suffix}`; + case 'uint256': + case 'reference': + case 'decimal': + return String(1000 + idx); + case 'int256': + return String(-100 - idx); + case 'bool': + return idx % 2 === 0; + case 'address': + case 'externalReference': + return accountAddress; + case 'bytes32': + return `0x${'ab'.repeat(32)}`; + default: + throw new Error(`Unsupported field type for generated tests: ${field.type}`); + } +} + +async function mustFail(promiseFactory, expectedHint) { + let failed = false; + try { + await promiseFactory(); + } catch (e) { + failed = true; + if (expectedHint) { + const msg = String(e?.message ?? e); + assert.match(msg, expectedHint, `Expected error hint ${expectedHint}, got: ${msg}`); + } + } + assert.equal(failed, true, 'Expected operation to fail but it succeeded.'); +} + +async function main() { + const root = process.cwd(); + const parent = path.resolve(root, '..'); + const schemaPath = path.join(parent, 'schema.json'); + const appSolPath = path.join(parent, 'contracts', 'App.sol'); + + const schema = mustReadJson(schemaPath); + const appSol = mustReadText(appSolPath); + const { abi, bytecode } = compileApp(appSol); + + const rpcUrl = process.env.TH_RPC_URL || DEFAULT_RPC_URL; + const privateKey = process.env.TH_TEST_PRIVATE_KEY || DEFAULT_PRIVATE_KEY; + const web3 = new Web3(rpcUrl); + + const listening = await web3.eth.net.isListening().catch(() => false); + if (!listening) { + throw new Error(`RPC is not reachable at ${rpcUrl}. Start anvil and retry.`); + } + + const account = web3.eth.accounts.privateKeyToAccount(privateKey); + web3.eth.accounts.wallet.add(account); + web3.eth.defaultAccount = account.address; + + const anyPaidCreates = (schema.collections || []).some((c) => Boolean(c?.createRules?.payment)); + const deployArgs = anyPaidCreates ? [account.address, account.address] : []; + + const app = await new web3.eth.Contract(abi) + .deploy({ data: bytecode, arguments: deployArgs }) + .send({ from: account.address, gas: 8_000_000 }); + + for (const collection of schema.collections || []) { + const name = String(collection.name); + const fields = Array.isArray(collection.fields) ? collection.fields : []; + const mutable = Array.isArray(collection?.updateRules?.mutable) ? collection.updateRules.mutable : []; + const softDelete = Boolean(collection?.deleteRules?.softDelete); + const hasTransfer = Boolean(collection?.transferRules); + const hasPayment = Boolean(collection?.createRules?.payment?.amountWei); + const optimistic = Boolean(collection?.updateRules?.optimisticConcurrency); + + const createFn = `create${name}`; + const listFn = `listIds${name}`; + const getFn = `get${name}(uint256)`; + const getWithDeletedFn = `get${name}(uint256,bool)`; + const updateFn = `update${name}`; + const deleteFn = `delete${name}`; + const transferFn = `transfer${name}`; + + const createArgs = fields.map((f, idx) => sampleValue(f, idx, false, account.address)); + + if (hasPayment) { + await mustFail(() => + app.methods[createFn](...createArgs).send({ from: account.address, gas: 3_000_000 }) + ); + + await app.methods[createFn](...createArgs).send({ + from: account.address, + gas: 3_000_000, + value: String(collection.createRules.payment.amountWei) + }); + } else { + await app.methods[createFn](...createArgs).send({ from: account.address, gas: 3_000_000 }); + } + + const ids = await app.methods[listFn](0, 20, false).call(); + assert.equal(Array.isArray(ids), true, `${listFn} must return an array`); + assert.equal(ids.length > 0, true, `${listFn} must include created record`); + const id = Number(ids[0]); + + const current = await app.methods[getFn](id).call(); + assert.ok(current, `${getFn} should return a record`); + + if (hasTransfer) { + const accounts = await web3.eth.getAccounts(); + const to = accounts[1] || account.address; + await app.methods[transferFn](id, to).send({ from: account.address, gas: 3_000_000 }); + const afterTransfer = await app.methods[getFn](id).call(); + assert.equal( + String(afterTransfer.owner || '').toLowerCase(), + String(to).toLowerCase(), + `${transferFn} should update owner` + ); + } + + if (mutable.length > 0) { + const updateArgs = [id]; + for (const mutableFieldName of mutable) { + const field = fields.find((f) => f?.name === mutableFieldName); + if (!field) continue; + updateArgs.push(sampleValue(field, 777, true, account.address)); + } + if (optimistic) updateArgs.push('0'); + + await app.methods[updateFn](...updateArgs).send({ from: account.address, gas: 3_000_000 }); + const afterUpdate = await app.methods[getFn](id).call(); + const firstMutable = mutable[0]; + const firstMutableField = fields.find((f) => f.name === firstMutable); + if (firstMutable && firstMutable in afterUpdate && firstMutableField) { + assert.equal( + String(afterUpdate[firstMutable]), + String(sampleValue(firstMutableField, 777, true, account.address)), + `${updateFn} should update mutable field ${firstMutable}` + ); + } + } + + if (softDelete) { + await app.methods[deleteFn](id).send({ from: account.address, gas: 3_000_000 }); + const deletedRecord = await app.methods[getWithDeletedFn](id, true).call(); + assert.equal(Boolean(deletedRecord.isDeleted), true, `${deleteFn} should mark isDeleted`); + + const activeIds = await app.methods[listFn](0, 20, false).call(); + const hasId = (activeIds || []).map((x) => String(x)).includes(String(id)); + assert.equal(hasId, false, `${listFn} should exclude soft-deleted record by default`); + } + } + + console.log('PASS contract integration scaffold'); +} + +main().catch((e) => { + console.error(String(e?.stack || e?.message || e)); + process.exit(1); +}); diff --git a/test/integration/testGeneratedAppContractTests.js b/test/integration/testGeneratedAppContractTests.js new file mode 100644 index 0000000..832a1b6 --- /dev/null +++ b/test/integration/testGeneratedAppContractTests.js @@ -0,0 +1,85 @@ +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; +} + +async function tryGetChainIdHex(rpcUrl) { + const res = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_chainId', params: [] }) + }); + if (!res.ok) return null; + const json = await res.json(); + return typeof json?.result === 'string' ? json.result : null; +} + +async function waitForRpc(rpcUrl, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + const hex = await tryGetChainIdHex(rpcUrl); + if (hex) return hex; + } catch { + // continue polling + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`Timed out waiting for RPC at ${rpcUrl}`); +} + +describe('Generated app contract tests', function () { + it('emits and runs schema-driven contract integration tests for canonical job-board output', async function () { + this.timeout(240000); + if (!hasAnvil()) this.skip(); + + const schemaPath = path.join(process.cwd(), 'apps', 'example', 'job-board.schema.json'); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-generated-app-tests-')); + const uiDir = path.join(outDir, 'ui'); + + const generateRes = runTh(['generate', schemaPath, '--out', outDir, '--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 port = 45000 + Math.floor(Math.random() * 1000); + const rpcUrl = `http://127.0.0.1:${port}`; + const anvil = spawn('anvil', ['--host', '127.0.0.1', '--port', String(port), '--chain-id', '31337'], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + try { + await waitForRpc(rpcUrl, 15000); + const testRes = runCmd('node', ['tests/contract/integration.mjs'], uiDir, { + TH_RPC_URL: rpcUrl + }); + expect(testRes.status, testRes.stderr || testRes.stdout).to.equal(0); + expect(testRes.stdout).to.include('PASS contract integration scaffold'); + } finally { + anvil.kill('SIGINT'); + } + }); +}); diff --git a/test/testCliGenerateUi.js b/test/testCliGenerateUi.js index a681ca3..30a1f9c 100644 --- a/test/testCliGenerateUi.js +++ b/test/testCliGenerateUi.js @@ -123,12 +123,15 @@ describe('th generate (UI template)', function () { const uiDir = path.join(outDir, 'ui'); expect(fs.existsSync(path.join(uiDir, 'tests', 'contract', 'smoke.mjs'))).to.equal(true); + expect(fs.existsSync(path.join(uiDir, 'tests', 'contract', 'integration.mjs'))).to.equal(true); expect(fs.existsSync(path.join(uiDir, 'tests', 'ui', 'smoke.mjs'))).to.equal(true); const pkg = JSON.parse(fs.readFileSync(path.join(uiDir, 'package.json'), 'utf-8')); expect(pkg?.scripts?.test).to.equal('pnpm run test:contract && pnpm run test:ui'); - expect(pkg?.scripts?.['test:contract']).to.equal('node tests/contract/smoke.mjs'); + expect(pkg?.scripts?.['test:contract']).to.equal('node tests/contract/integration.mjs'); expect(pkg?.scripts?.['test:ui']).to.equal('node tests/ui/smoke.mjs'); + expect(pkg?.devDependencies?.solc).to.equal('0.8.24'); + expect(pkg?.devDependencies?.web3).to.equal('^1.3.5'); const contractSmoke = runCmd('node', ['tests/contract/smoke.mjs'], uiDir); expect(contractSmoke.status, contractSmoke.stderr || contractSmoke.stdout).to.equal(0); From bb90694c98ec279c9f6fa95943b1cd9372563f77 Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 5 Feb 2026 10:14:46 -1000 Subject: [PATCH 2/2] test: add generated UI live smoke integration coverage --- .../test-scaffold/tests/README.md | 5 + .../test-scaffold/tests/ui/smoke.mjs | 66 +++++++++++- test/integration/testGeneratedAppUiTests.js | 102 ++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 test/integration/testGeneratedAppUiTests.js 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'); + } + }); +});