diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..a5856bd --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,58 @@ +name: E2E Test Suite + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + e2e: + name: Run E2E Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Modern Rust Toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install stellar-cli + uses: stellar/setup-stellar-cli@v1 + with: + version: 21.6.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: keeper/package-lock.json + + - name: Install Keeper Dependencies + run: | + cd keeper + npm install + + - name: Start Soroban Network + run: | + chmod +x scripts/start-network.sh + ./scripts/start-network.sh + + - name: Setup Contracts + run: | + chmod +x scripts/setup-contracts.sh + ./scripts/setup-contracts.sh + + - name: Run E2E Test + run: | + cd keeper + node __tests__/e2e-run.js + + - name: Export Logs on Failure + if: failure() + run: | + docker logs soroban-local diff --git a/keeper/__tests__/e2e-run.js b/keeper/__tests__/e2e-run.js new file mode 100644 index 0000000..8d28201 --- /dev/null +++ b/keeper/__tests__/e2e-run.js @@ -0,0 +1,145 @@ +const { + Keypair, + rpc, + Contract, + TransactionBuilder, + BASE_FEE, + Networks, + Address, + scValToNative, +} = require('@stellar/stellar-sdk'); +const { spawn, execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const dotenv = require('dotenv'); + +// Load .env.test from the root +const envPath = path.resolve(__dirname, '../../.env.test'); +if (!fs.existsSync(envPath)) { + console.error('.env.test not found. Run scripts/setup-contracts.sh first.'); + process.exit(1); +} + +const envConfig = dotenv.parse(fs.readFileSync(envPath)); +for (const k in envConfig) { + process.env[k] = envConfig[k]; +} + +const RPC_URL = process.env.SOROBAN_RPC_URL; +const NETWORK_PASSPHRASE = process.env.NETWORK_PASSPHRASE; +const CONTRACT_ID = process.env.CONTRACT_ID; +const TARGET_ID = process.env.TARGET_ID; +const CREATOR_SECRET = process.env.CREATOR_SECRET; + +const server = new rpc.Server(RPC_URL); +const creatorKeypair = Keypair.fromSecret(CREATOR_SECRET); + +async function registerTask() { + console.log('Registering a task using stellar-cli...'); + + // Construct the command + // Note: we use --config as the argument name because that's what's in lib.rs: register(env, config) + const cmd = `stellar contract invoke \ + --id ${CONTRACT_ID} \ + --source creator \ + --network local \ + -- \ + register \ + --config '{ + "creator": "${creatorKeypair.publicKey()}", + "target": "${TARGET_ID}", + "function": "get_token", + "args": [], + "resolver": null, + "interval": 5, + "last_run": 0, + "gas_balance": "1000", + "whitelist": [], + "is_active": true + }'`; + + console.log('Executing:', cmd); + const output = execSync(cmd, { encoding: 'utf8' }); + console.log('CLI Output:', output); + + // Extract task ID from output (it's usually the last line or just the value) + const taskId = parseInt(output.trim(), 10); + console.log('Task ID registered:', taskId); + return taskId; +} + +async function getTaskLastRun(taskId) { + const contract = new Contract(CONTRACT_ID); + const result = await server.simulateTransaction( + new TransactionBuilder(await server.getAccount(creatorKeypair.publicKey()), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(contract.call('get_task', rpc.nativeToScVal(taskId, { type: 'u64' }))) + .setTimeout(30) + .build() + ); + + if (result.error) { + throw new Error('Failed to get task: ' + JSON.stringify(result.error)); + } + + const task = scValToNative(result.result.retval); + return task ? task.last_run : null; +} + +async function runTest() { + try { + const taskId = await registerTask(); + const initialLastRun = await getTaskLastRun(taskId); + console.log('Initial last_run:', initialLastRun); + + console.log('Starting keeper process...'); + // Ensure we are in the keeper directory + const keeperDir = path.resolve(__dirname, '..'); + + const keeper = spawn('node', ['index.js'], { + cwd: keeperDir, + env: { + ...process.env, + POLLING_INTERVAL_MS: '2000', + LOG_LEVEL: 'debug', + // Make sure data dir is clean for E2E + DATA_DIR: path.join(keeperDir, 'data-e2e') + }, + stdio: 'inherit', + }); + + console.log('Waiting for keeper to execute task...'); + let executed = false; + const startTime = Date.now(); + const timeout = 60000; // 60 seconds + + while (Date.now() - startTime < timeout) { + await new Promise((r) => setTimeout(r, 5000)); + const currentLastRun = await getTaskLastRun(taskId); + console.log('Current last_run:', currentLastRun); + + if (currentLastRun > initialLastRun) { + console.log('SUCCESS: Task executed!'); + executed = true; + break; + } + } + + keeper.kill('SIGINT'); + + if (!executed) { + console.error('FAILED: Task was not executed within timeout'); + process.exit(1); + } + + console.log('E2E Test Passed!'); + process.exit(0); + } catch (err) { + console.error('Error during E2E test:', err); + process.exit(1); + } +} + +runTest(); diff --git a/scripts/setup-contracts.sh b/scripts/setup-contracts.sh new file mode 100755 index 0000000..2b5fb42 --- /dev/null +++ b/scripts/setup-contracts.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -e + +# Configuration +RPC_URL="http://localhost:8000/soroban/rpc" +NETWORK_PASSPHRASE="Local Sandbox Stellar Network ; September 2018" + +# 1. Setup Network in CLI +echo "Configuring stellar-cli network..." +stellar network add --rpc-url "$RPC_URL" --network-passphrase "$NETWORK_PASSPHRASE" local || true + +# 2. Generate and Fund Identities +echo "Generating and funding identities..." +for name in deployer keeper creator; do + stellar keys generate --network local $name || true + # Funding is usually automatic with --network local if quickstart is running, + # but we can try to fund again just in case. + # stellar keys fund $name --network local || true +done + +# 3. Build Contracts +echo "Building contracts..." +(cd contract && cargo build --target wasm32-unknown-unknown --release) + +WASM_PATH="contract/target/wasm32-unknown-unknown/release/soro_task_contract.wasm" + +# 4. Deploy Main Contract +echo "Deploying SoroTask contract..." +CONTRACT_ID=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source deployer \ + --network local) + +echo "CONTRACT_ID: $CONTRACT_ID" + +# 5. Deploy Mock Target Contract +echo "Deploying Mock Target contract..." +TARGET_ID=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source deployer \ + --network local) + +echo "TARGET_ID: $TARGET_ID" + +# 6. Deploy Native Token Contract +echo "Deploying Native Token contract..." +# Note: stellar contract asset deploy --asset native is deprecated in some versions but works in 21.x.x +# Some versions use stellar contract id asset native +TOKEN_ID=$(stellar contract id asset --asset native --network local || stellar contract id asset native --network local) +stellar contract asset deploy --asset native --source deployer --network local || true +echo "TOKEN_ID: $TOKEN_ID" + +# 7. Initialize Main Contract +echo "Initializing SoroTask contract..." +stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source deployer \ + --network local \ + -- \ + init --token "$TOKEN_ID" + +# Save addresses for test +cat < .env.test +SOROBAN_RPC_URL="$RPC_URL" +NETWORK_PASSPHRASE="$NETWORK_PASSPHRASE" +CONTRACT_ID="$CONTRACT_ID" +TARGET_ID="$TARGET_ID" +TOKEN_ID="$TOKEN_ID" +KEEPER_SECRET="$(stellar keys show keeper)" +CREATOR_SECRET="$(stellar keys show creator)" +POLLING_INTERVAL_MS=2000 +LOG_LEVEL=debug +EOF + +echo "Setup COMPLETE!" diff --git a/scripts/start-network.sh b/scripts/start-network.sh new file mode 100755 index 0000000..b9021cd --- /dev/null +++ b/scripts/start-network.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +# Configuration +QUICKSTART_IMAGE="stellar/quickstart:latest" +NETWORK_NAME="soroban-local" +RPC_PORT=8000 + +# Cleanup old container if it exists +if [ "$(docker ps -aq -f name=$NETWORK_NAME)" ]; then + echo "Stopping and removing existing $NETWORK_NAME container..." + docker stop $NETWORK_NAME || true + docker rm $NETWORK_NAME || true +fi + +echo "Starting Soroban local network (quickstart)..." +docker run -d \ + --name $NETWORK_NAME \ + -p $RPC_PORT:8000 \ + $QUICKSTART_IMAGE \ + --local \ + --enable-soroban-rpc + +# Wait for RPC to be ready +echo "Waiting for Soroban RPC to be ready..." +MAX_RETRIES=30 +RETRY_COUNT=0 +until curl -s -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"1","method":"getNetwork"}' \ + http://localhost:$RPC_PORT/soroban/rpc > /dev/null; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Error: Soroban RPC failed to start after $MAX_RETRIES attempts." + docker logs $NETWORK_NAME + exit 1 + fi + echo "Still waiting... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 5 +done + +echo "Soroban local network is READY!"