|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Production-readiness e2e gate. |
| 4 | + * |
| 5 | + * Deterministic, no devnet/Docker/LLM required: |
| 6 | + * 1. build the local runtime package the feed imports, |
| 7 | + * 2. start the real marketplace feed server against a temporary Coral extended-state fixture, |
| 8 | + * 3. assert health/feed/threads/runs/proof-receipts over HTTP, |
| 9 | + * 4. smoke the TxODDS Agent Desk static JS + Tauri JSON configs. |
| 10 | + */ |
| 11 | +import { spawn, spawnSync } from 'node:child_process' |
| 12 | +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 13 | +import { platform, tmpdir } from 'node:os' |
| 14 | +import { dirname, join } from 'node:path' |
| 15 | +import { fileURLToPath } from 'node:url' |
| 16 | + |
| 17 | +const root = join(dirname(fileURLToPath(import.meta.url)), '..') |
| 18 | +const feedDir = join(root, 'examples', 'marketplace', 'feed') |
| 19 | +const runtimeDir = join(root, 'packages', 'agent-runtime') |
| 20 | +const deskDir = join(root, 'examples', 'txodds-agent-desk') |
| 21 | +const port = process.env.READINESS_PORT ?? String(4100 + Math.floor(Math.random() * 1000)) |
| 22 | +const base = `http://localhost:${port}` |
| 23 | +const tmp = mkdtempSync(join(tmpdir(), 'pay-readiness-')) |
| 24 | +const fixturePath = join(tmp, 'coral-session-with-proof.json') |
| 25 | +const runsDir = join(tmp, 'runs') |
| 26 | + |
| 27 | +function run(cwd, cmd, args) { |
| 28 | + const r = spawnSync(cmd, args, { cwd, shell: true, stdio: 'inherit' }) |
| 29 | + if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed in ${cwd}`) |
| 30 | +} |
| 31 | + |
| 32 | +function stop(child) { |
| 33 | + if (child.exitCode != null || child.pid == null) return |
| 34 | + if (platform() === 'win32') { |
| 35 | + spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { shell: true, stdio: 'ignore' }) |
| 36 | + } else { |
| 37 | + try { process.kill(-child.pid, 'SIGTERM') } catch { child.kill('SIGTERM') } |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function assert(condition, message) { |
| 42 | + if (!condition) throw new Error(message) |
| 43 | +} |
| 44 | + |
| 45 | +function fixture() { |
| 46 | + const threadId = 'readiness-thread' |
| 47 | + const ts = (n) => `2026-07-06T12:00:${String(n).padStart(2, '0')}.000Z` |
| 48 | + const msg = (senderName, text, mentionNames, i) => ({ senderName, text, threadId, mentionNames, timestamp: ts(i) }) |
| 49 | + const agents = ['buyer-agent', 'seller-premium', 'verifier-agent'].map((name) => ({ |
| 50 | + name, |
| 51 | + status: { type: 'running' }, |
| 52 | + registryAgentIdentifier: { name, version: '0.1.0', registrySourceId: { type: 'local' } }, |
| 53 | + })) |
| 54 | + const messages = [ |
| 55 | + msg('buyer-agent', 'WANT round=42 service=txline arg=edge-9001 budget=0.001', ['seller-premium'], 1), |
| 56 | + msg('seller-premium', 'BID round=42 price=0.0005 by=seller-premium note=readiness-proof', ['buyer-agent'], 2), |
| 57 | + msg('buyer-agent', 'AWARD round=42 to=seller-premium reason="readiness e2e winner"', ['seller-premium'], 3), |
| 58 | + msg('seller-premium', 'ESCROW_REQUIRED round=42 reference=Ref42 seller=7jwB amount=0.0005 deadline=600 settlement=arbiter', ['buyer-agent'], 4), |
| 59 | + msg('buyer-agent', 'DEPOSITED round=42 reference=Ref42 buyer=47Dp sig=dep42 settlement=arbiter vault=Vault42 arbiter=Arb42', ['seller-premium'], 5), |
| 60 | + msg('seller-premium', 'PAYMENT_REQUIRED round=42 rail=pay-sh amount=0.03 currency=USDC reference=pay-42 seller=pay.sh/txodds-context url=https://pay.sh/api/quicknode', [], 6), |
| 61 | + msg('seller-premium', 'PAYMENT_PROOF round=42 rail=pay-sh reference=pay-42 proof=pay-sh-demo:readiness buyer=seller-premium', [], 7), |
| 62 | + msg('seller-premium', 'PAYMENT_CONFIRMED round=42 rail=pay-sh reference=pay-42 paid=true amount=0.03 currency=USDC', [], 8), |
| 63 | + msg('seller-premium', 'DELIVERED round=42 {"service":"txline-edge","fixtureId":"9001","analysis":{"call":"readiness fixture delivered"}}', ['buyer-agent'], 9), |
| 64 | + msg('buyer-agent', 'VERIFY round=42 sha=abc service=txline arg=edge-9001 payload={"ok":true}', ['verifier-agent'], 10), |
| 65 | + msg('verifier-agent', 'VERIFIED round=42 verdict=pass by=verifier-agent reason="readiness hash + structure"', ['buyer-agent'], 11), |
| 66 | + msg('buyer-agent', 'ARBITER_RELEASED round=42 sig=rel42 settlement=arbiter', ['seller-premium'], 12), |
| 67 | + ] |
| 68 | + return { |
| 69 | + base: { id: 'readiness-session', namespace: 'default', status: { type: 'executed' } }, |
| 70 | + agents, |
| 71 | + threads: [{ id: threadId, name: 'market', creatorName: 'buyer-agent', participants: agents.map((a) => a.name), messages }], |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +async function waitForHealth(child) { |
| 76 | + const deadline = Date.now() + 30_000 |
| 77 | + let last = '' |
| 78 | + while (Date.now() < deadline) { |
| 79 | + if (child.exitCode != null) throw new Error(`feed server exited early: ${child.exitCode}`) |
| 80 | + try { |
| 81 | + const r = await fetch(`${base}/api/health`) |
| 82 | + if (r.ok) return |
| 83 | + last = `HTTP ${r.status}` |
| 84 | + } catch (e) { |
| 85 | + last = e.message |
| 86 | + } |
| 87 | + await new Promise((resolve) => setTimeout(resolve, 500)) |
| 88 | + } |
| 89 | + throw new Error(`feed server did not become healthy: ${last}`) |
| 90 | +} |
| 91 | + |
| 92 | +async function json(path) { |
| 93 | + const r = await fetch(`${base}${path}`) |
| 94 | + const body = await r.text() |
| 95 | + assert(r.ok, `${path} failed: HTTP ${r.status} ${body.slice(0, 200)}`) |
| 96 | + return JSON.parse(body) |
| 97 | +} |
| 98 | + |
| 99 | +async function main() { |
| 100 | + if (Number(process.versions.node.split('.')[0]) < 20) { |
| 101 | + throw new Error(`Node ${process.version} detected; readiness gate requires Node 20+`) |
| 102 | + } |
| 103 | + assert(existsSync(join(root, 'docs', 'PRODUCTION_READINESS.md')), 'docs/PRODUCTION_READINESS.md is missing') |
| 104 | + |
| 105 | + if (!existsSync(join(runtimeDir, 'node_modules'))) run(runtimeDir, 'npm', ['install', '--no-audit', '--no-fund']) |
| 106 | + run(runtimeDir, 'npm', ['run', 'build']) |
| 107 | + if (!existsSync(join(feedDir, 'node_modules'))) run(feedDir, 'npm', ['install', '--no-audit', '--no-fund']) |
| 108 | + |
| 109 | + writeFileSync(fixturePath, JSON.stringify(fixture(), null, 2), 'utf8') |
| 110 | + |
| 111 | + const feed = spawn('npm', ['start'], { |
| 112 | + cwd: feedDir, |
| 113 | + shell: true, |
| 114 | + detached: platform() !== 'win32', |
| 115 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 116 | + env: { ...process.env, FEED_FIXTURE: fixturePath, RUNS_DIR: runsDir, PORT: port }, |
| 117 | + }) |
| 118 | + feed.stdout.on('data', (d) => process.stdout.write(`[feed] ${d}`)) |
| 119 | + feed.stderr.on('data', (d) => process.stderr.write(`[feed] ${d}`)) |
| 120 | + |
| 121 | + try { |
| 122 | + await waitForHealth(feed) |
| 123 | + |
| 124 | + const health = await json('/api/health') |
| 125 | + assert(health.ok === true, 'health endpoint did not return ok=true') |
| 126 | + |
| 127 | + const feedBody = await json('/api/feed?session=fixture') |
| 128 | + const round = feedBody.rounds?.find((r) => r.round === 42) |
| 129 | + assert(round?.status === 'settled', 'readiness round did not settle') |
| 130 | + assert(round?.verification?.verdict === 'pass', 'verifier pass did not fold into the round') |
| 131 | + assert(round?.proofReceipts?.[0]?.rail === 'pay-sh', 'proof receipt did not fold into the round') |
| 132 | + assert(round.proofReceipts[0].simulated === true, 'demo Pay.sh proof receipt was not marked simulated') |
| 133 | + |
| 134 | + const runs = await json('/api/runs') |
| 135 | + const ledgerRun = runs.runs?.find((r) => r.runId === 'fixture/round-42') |
| 136 | + assert(ledgerRun?.proofReceipts?.[0]?.proof === 'pay-sh-demo:readiness', 'ledger run is missing proof receipt') |
| 137 | + |
| 138 | + const threads = await json('/api/threads?session=fixture') |
| 139 | + assert(threads.threads?.[0]?.messages?.length >= 12, 'thread replay did not expose the bus transcript') |
| 140 | + assert(threads.agents?.some((a) => a.name === 'verifier-agent'), 'agent roster missing verifier-agent') |
| 141 | + |
| 142 | + const receiptFile = join(runsDir, 'fixture', 'round-42', 'proof_receipts.json') |
| 143 | + assert(existsSync(receiptFile), 'proof_receipts.json was not written') |
| 144 | + const receipt = JSON.parse(readFileSync(receiptFile, 'utf8'))[0] |
| 145 | + assert(receipt.provider === 'pay.sh/txodds-context', 'proof receipt provider was not preserved') |
| 146 | + |
| 147 | + run(deskDir, 'node', ['--check', 'ui/app.js']) |
| 148 | + JSON.parse(readFileSync(join(deskDir, 'src-tauri', 'tauri.conf.json'), 'utf8')) |
| 149 | + JSON.parse(readFileSync(join(deskDir, 'src-tauri', 'capabilities', 'default.json'), 'utf8')) |
| 150 | + |
| 151 | + console.log('[readiness] PASS production-readiness e2e gate') |
| 152 | + } finally { |
| 153 | + stop(feed) |
| 154 | + rmSync(tmp, { recursive: true, force: true }) |
| 155 | + } |
| 156 | +} |
| 157 | + |
| 158 | +main().catch((e) => { |
| 159 | + console.error(`[readiness] FAIL ${e.stack ?? e}`) |
| 160 | + process.exitCode = 1 |
| 161 | +}) |
0 commit comments