diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..399c18fe --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,33 @@ +name: Integration Tests + +on: + workflow_dispatch: + push: + paths: + - 'contracts/**' + - 'packages/**' + - 'docker/**' + - '.github/workflows/integration-tests.yml' + +jobs: + integration: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build test network image + run: | + docker build -t subtrackr/stellar-standalone:test ./docker + + - name: Start test network + run: | + docker-compose -f docker-compose.test.yml up -d --build + + - name: Wait and run integration script + run: | + chmod +x ./scripts/test-integration.sh + ./scripts/test-integration.sh diff --git a/contracts/test-helpers/deploy.ts b/contracts/test-helpers/deploy.ts new file mode 100644 index 00000000..8328727b --- /dev/null +++ b/contracts/test-helpers/deploy.ts @@ -0,0 +1,5 @@ +import { deployContract } from '../../packages/test-harness/dist/index.js'; + +export async function deploy(wasmHex: string) { + return deployContract(wasmHex); +} diff --git a/contracts/test-helpers/fund.ts b/contracts/test-helpers/fund.ts new file mode 100644 index 00000000..ffc4c59e --- /dev/null +++ b/contracts/test-helpers/fund.ts @@ -0,0 +1,5 @@ +import { fundAccount } from '../../packages/test-harness/dist/index.js'; + +export async function fund(publicKey: string) { + return fundAccount(publicKey); +} diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 00000000..c6f478a9 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + stellar-standalone: + build: + context: ./docker + dockerfile: stellar-standalone.Dockerfile + image: subtrackr/stellar-standalone:test + ports: + - '8000:8000' + - '11626:11626' + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/"] + interval: 5s + timeout: 3s + retries: 12 + shm_size: '64mb' diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 00000000..b8ef957b --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +echo "[entrypoint] initializing soroban standalone placeholder" + +# start the placeholder soroban server +/opt/soroban/soroban-server & +SERVER_PID=$! + +# simple readiness probe +echo "waiting for HTTP health on :8000" +MAX=30 +COUNT=0 +until curl -sSf http://localhost:8000/ >/dev/null 2>&1 || [ "$COUNT" -ge "$MAX" ]; do + sleep 1 + COUNT=$((COUNT+1)) +done + +if [ "$COUNT" -ge "$MAX" ]; then + echo "[entrypoint] soroban server failed to start" + kill "$SERVER_PID" || true + exit 1 +fi + +echo "[entrypoint] soroban ready" +wait "$SERVER_PID" diff --git a/docker/stellar-standalone.Dockerfile b/docker/stellar-standalone.Dockerfile new file mode 100644 index 00000000..96b6f99a --- /dev/null +++ b/docker/stellar-standalone.Dockerfile @@ -0,0 +1,17 @@ +# Minimal Docker image for a Soroban/Stellar standalone test network +# This image is intended for CI/local integration tests only. +FROM --platform=linux/amd64 ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y curl ca-certificates jq netcat-openbsd git && rm -rf /var/lib/apt/lists/* + +# Placeholder soroban server binary - CI should replace with real soroban/stellar standalone binary +RUN mkdir -p /opt/soroban && cat > /opt/soroban/soroban-server <<'EOF'\n#!/bin/sh\necho "[soroban-standalone] starting (placeholder)"\n# simple HTTP health endpoint using netcat in background\n(while true; do echo -e "HTTP/1.1 200 OK\n\nOK" | nc -l -p 8000 -q 1; done) &\n# keep container running\nwhile sleep 3600; do :; done\nEOF\nRUN chmod +x /opt/soroban/soroban-server + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 8000 11626 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/packages/test-harness/package.json b/packages/test-harness/package.json new file mode 100644 index 00000000..3fb4faba --- /dev/null +++ b/packages/test-harness/package.json @@ -0,0 +1,17 @@ +{ + "name": "@subtrackr/test-harness", + "version": "0.1.0", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "test:integration": "node --enable-source-maps ./dist/runner.js" + }, + "dependencies": { + "node-fetch": "^2.6.7" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/test-harness/src/index.ts b/packages/test-harness/src/index.ts new file mode 100644 index 00000000..f4384c96 --- /dev/null +++ b/packages/test-harness/src/index.ts @@ -0,0 +1,42 @@ +import fetch from 'node-fetch'; + +const DEFAULT_RPC = process.env.SOROBAN_RPC_URL || 'http://localhost:8000'; + +export async function deployContract(wasmHex: string) { + // Placeholder: submit a contract deploy to the RPC + const resp = await fetch(`${DEFAULT_RPC}/deploy`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ wasm: wasmHex }) + }); + return resp.json(); +} + +export async function fundAccount(publicKey: string) { + // Use friendbot-style endpoint if available on the standalone server + const resp = await fetch(`${DEFAULT_RPC}/friendbot?addr=${encodeURIComponent(publicKey)}`); + return resp.json(); +} + +export async function invokeContract(payload: any) { + const MAX_RETRIES = 2; + let attempts = 0; + while (true) { + attempts++; + const resp = await fetch(`${DEFAULT_RPC}/invoke`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload) + }); + const body = await resp.json(); + if (resp.ok) return body; + // simple retry on sequence number error + if (body && body.error && body.error.includes && body.error.includes('sequence')) { + if (attempts <= MAX_RETRIES) { + await new Promise((r) => setTimeout(r, 500)); + continue; + } + } + throw new Error(JSON.stringify(body)); + } +} diff --git a/packages/test-harness/src/runner.ts b/packages/test-harness/src/runner.ts new file mode 100644 index 00000000..639ad341 --- /dev/null +++ b/packages/test-harness/src/runner.ts @@ -0,0 +1,27 @@ +import { deployContract, fundAccount, invokeContract } from './index'; + +async function main() { + console.log('[test-harness] runner starting'); + // Placeholder runner: demonstrate API usage + try { + const acct = 'GTESTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + console.log('[test-harness] funding', acct); + const fund = await fundAccount(acct); + console.log('[test-harness] funded', fund); + + console.log('[test-harness] deploying contract (placeholder)'); + const deploy = await deployContract('00'); + console.log('[test-harness] deploy result', deploy); + + console.log('[test-harness] invoking contract (placeholder)'); + const inv = await invokeContract({ contract: '0x00', args: [] }); + console.log('[test-harness] invoke result', inv); + } catch (err) { + console.error('[test-harness] error', err); + process.exit(2); + } + + console.log('[test-harness] runner complete'); +} + +main(); diff --git a/packages/test-harness/tsconfig.json b/packages/test-harness/tsconfig.json new file mode 100644 index 00000000..fa135de5 --- /dev/null +++ b/packages/test-harness/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "declaration": true, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/scripts/test-integration.sh b/scripts/test-integration.sh new file mode 100644 index 00000000..6100df45 --- /dev/null +++ b/scripts/test-integration.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Integration test runner: builds the test network, snapshots, runs tests, and tears down. +ROOT_DIR=$(cd "$(dirname "$0")/.." && pwd) +COMPOSE_FILE="$ROOT_DIR/docker-compose.test.yml" + +echo "[test-integration] starting docker-compose up" +docker-compose -f "$COMPOSE_FILE" up -d --build + +echo "[test-integration] waiting for service healthy" +docker-compose -f "$COMPOSE_FILE" ps + +# wait for health +timeout=60 +elapsed=0 +until docker-compose -f "$COMPOSE_FILE" exec -T stellar-standalone sh -c "curl -sSf http://localhost:8000/ >/dev/null" >/dev/null 2>&1 || [ $elapsed -ge $timeout ]; do + sleep 1 + elapsed=$((elapsed+1)) +done + +if [ $elapsed -ge $timeout ]; then + echo "Service failed to become healthy" + docker-compose -f "$COMPOSE_FILE" logs + exit 1 +fi + +export SOROBAN_RPC_URL=http://localhost:8000 +export TEST_TIMEOUT_MS=30000 + +echo "[test-integration] running tests with SOROBAN_RPC_URL=$SOROBAN_RPC_URL" +# Run workspace tests (assumes tests are configured to use packages/test-harness helpers) +if command -v yarn >/dev/null 2>&1; then + yarn workspace @subtrackr/test-harness test:integration --runInBand || TEST_EXIT=$? +else + npm --workspace ./packages/test-harness run test:integration || TEST_EXIT=$? +fi + +TEST_EXIT=${TEST_EXIT:-0} + +echo "[test-integration] tearing down" +docker-compose -f "$COMPOSE_FILE" down --volumes --remove-orphans + +exit $TEST_EXIT