Skip to content
Open
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
5 changes: 1 addition & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: 22
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 20
cache: "npm"

- name: Install dependencies
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"",
Expand Down
5 changes: 5 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
100 changes: 100 additions & 0 deletions tests/smoke.test.js
Original file line number Diff line number Diff line change
@@ -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)
})