From 5d40ed9b465da6c7fd68c45e4788042c70f632b9 Mon Sep 17 00:00:00 2001 From: Rufai-Ahmed Date: Thu, 23 Jul 2026 16:28:55 +0100 Subject: [PATCH] ci: add API smoke check, fix lint workflow parse error - add npm run smoke (tests/smoke.test.js) which boots the server and probes /healthz, /api/status and /api/agents; each request has a 5s timeout so a route that accepts but never responds fails the check and still prints the captured server output instead of stalling until the job timeout - run smoke as its own PR-blocking job in the Tests workflow - lint.yml declared node-version as a scalar matrix, which made the workflow fail parsing on every run; pin node 20 directly - server.js used the request validation middleware without importing it, so the server crashed at boot (caught by the smoke check) Known issue, needs maintainer sign-off: the Lint workflow never ran before this change (it failed at parse time), so fixing it surfaces pre-existing debt: 595 eslint errors (all prettier/prettier) and 19 files failing format:check. The Lint check stays red until a repo-wide "npm run format" lands; that single command clears both steps (verified locally: lint exits 0 with 21 warnings, format:check passes). lint:fix alone is not enough since format:check also covers markdown files. The mechanical ~1500 line format diff is deliberately not bundled here to keep this PR reviewable. --- .github/workflows/lint.yml | 5 +- .github/workflows/test.yml | 19 +++++++ package.json | 1 + src/server.js | 5 ++ tests/smoke.test.js | 100 +++++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tests/smoke.test.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1d88d91..dc95880 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,9 +9,6 @@ on: jobs: lint: runs-on: ubuntu-latest - strategy: - matrix: - node-version: 22 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -19,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 20 cache: "npm" - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a31211a..40183a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,3 +29,22 @@ jobs: # failure here is unambiguous even if install/other suites have issues. - name: Budget guardrail tests run: npm run test:budget + + smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: API smoke check + run: npm run smoke diff --git a/package.json b/package.json index 1ef35fc..9bf9a80 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "setup:usdc": "node src/setup-usdc.js", "test": "node src/agents/settlement-header.test.js && node tests/orchestrator.budget.test.js && node tests/premium-endpoints.integration.test.js", "test:validation": "node tests/api.validation.test.js", + "smoke": "node tests/smoke.test.js", "lint": "eslint . --ext .js,.mjs,.cjs", "lint:fix": "eslint . --ext .js,.mjs,.cjs --fix", "format": "prettier --write \"**/*.{js,md,html,yaml,yml}\"", diff --git a/src/server.js b/src/server.js index 576fafd..0022d0b 100644 --- a/src/server.js +++ b/src/server.js @@ -19,6 +19,11 @@ import { orchestrate } from './agents/orchestrator.js' import { getBalance, getTransactions } from './stellar/wallet.js' import { requestId, requestLogger, errorHandler } from './middleware/errorHandler.js' import { apikeyLimiter } from './middleware/rateLimiter.js' +import { + validatePremiumQuery, + validateOrchestrate, + validateWalletTransactions, +} from './requestValidation.js' import { logger } from './logger.js' import { adminAuth } from './middleware/auth.js' import { registerPremiumRoutes } from './routes/premium-routes.js' diff --git a/tests/smoke.test.js b/tests/smoke.test.js new file mode 100644 index 0000000..bcf7196 --- /dev/null +++ b/tests/smoke.test.js @@ -0,0 +1,100 @@ +import assert from 'node:assert' +import { spawn } from 'node:child_process' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' + +const port = Number.parseInt(process.env.SMOKE_PORT, 10) || 3105 +const baseUrl = `http://127.0.0.1:${port}` +const serverPath = fileURLToPath(new URL('../src/server.js', import.meta.url)) +const startupTimeoutMs = 20000 +const requestTimeoutMs = 5000 + +// Startup pricing validation requires a non-empty payTo address. A placeholder +// is fine here: x402 middleware init failures are caught by the server and the +// smoke check only exercises unauthenticated endpoints. +const child = spawn(process.execPath, [serverPath], { + env: { + ...process.env, + PORT: String(port), + SERVER_STELLAR_ADDRESS: process.env.SERVER_STELLAR_ADDRESS || 'GSMOKE_PLACEHOLDER', + RUN_HISTORY_STORAGE: 'memory', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}) + +let serverOutput = '' +let exited = false +child.stdout.on('data', (chunk) => { + serverOutput += chunk +}) +child.stderr.on('data', (chunk) => { + serverOutput += chunk +}) +child.on('exit', () => { + exited = true +}) +child.on('error', (err) => { + console.error(`Smoke check failed: could not spawn server: ${err.message}`) + process.exit(1) +}) + +// Bounded requests: a route that accepts the connection but never responds +// must fail the check (and print the server output) instead of hanging until +// the job timeout cancels the run. +function get(path) { + return fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(requestTimeoutMs) }) +} + +async function waitForHealthz() { + const deadline = Date.now() + startupTimeoutMs + while (Date.now() < deadline) { + if (exited) { + throw new Error('server exited before becoming healthy') + } + try { + const res = await get('/healthz') + if (res.ok) return res.json() + } catch { + // server not accepting connections yet + } + await delay(250) + } + throw new Error(`server did not respond on /healthz within ${startupTimeoutMs}ms`) +} + +async function main() { + console.log('Smoke check: booting server on port ' + port) + + const health = await waitForHealthz() + assert.strictEqual(health.status, 'ok', '/healthz should report status ok') + console.log('PASS /healthz responds with status ok') + + const statusRes = await get('/api/status') + assert.strictEqual(statusRes.status, 200, '/api/status should return 200') + const status = await statusRes.json() + assert.strictEqual(status.status, 'online', '/api/status should report online') + console.log('PASS /api/status reports online') + + const agentsRes = await get('/api/agents') + assert.strictEqual(agentsRes.status, 200, '/api/agents should return 200') + const agents = await agentsRes.json() + assert(Array.isArray(agents) && agents.length > 0, '/api/agents should list agents') + console.log(`PASS /api/agents lists ${agents.length} agents`) + + console.log('Smoke check passed') +} + +main() + .then(() => { + child.kill('SIGTERM') + process.exit(0) + }) + .catch((err) => { + console.error(`Smoke check failed: ${err.message}`) + if (serverOutput) { + console.error('--- server output ---') + console.error(serverOutput) + } + child.kill('SIGTERM') + process.exit(1) + })