diff --git a/config/networks/sandbox.env b/config/networks/sandbox.env new file mode 100644 index 00000000..255fc408 --- /dev/null +++ b/config/networks/sandbox.env @@ -0,0 +1,28 @@ +# ============================================================= +# TeachLink Sandbox Network Configuration +# Issue #381 — Local testing sandbox environment +# ============================================================= +# This config is for LOCAL development only. +# Never use real keys or mainnet values here. + +STELLAR_NETWORK=sandbox +STELLAR_HORIZON_URL=http://localhost:8000 +STELLAR_SOROBAN_RPC_URL=http://localhost:8000/soroban/rpc + +# Pre-funded sandbox deployer key (safe to commit - sandbox only) +DEPLOYER_SECRET_KEY=SCZANGBA5YHTNYVS23C4QSQH5ODHVIMTQJZNFNLXFMG7VZ57SB42ONHU + +# Pre-funded sandbox test account keys +TEST_ACCOUNT_1_SECRET=SDHOAMBNLGCE27Q64SHG6KBSSRMLV3QLVJSRMGM65JJXTVFVKGAL4LGS +TEST_ACCOUNT_2_SECRET=SBQPDFUGLMWJYEYXFRM5TQX3AX2BR47WKI8FDS2VAKZ4YKQZRP64FGP5 +TEST_ACCOUNT_3_SECRET=SAVDOZS4FVSYLBCM7YIMHZUZSZSZEZM7HHQMHW6WIZ3QEG7A7BXQZAAH + +# Sandbox timing (faster than real network) +SANDBOX_BLOCK_TIME_MS=500 +SANDBOX_TX_TIMEOUT_SECS=10 + +# Mock service ports +MOCK_HORIZON_PORT=8000 +MOCK_TOKEN_PORT=8001 +SANDBOX_RPC_PORT=8002 +EOF diff --git a/docker-compose.yml b/docker-compose.yml index 1f4a37ca..1bceac9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,62 @@ version: '3.8' services: - # Development environment with all tools + # ───────────────────────────────────────────── + # LOCAL STELLAR NODE (sandbox network) + # Issue #381 — provides isolated local blockchain + # ───────────────────────────────────────────── + stellar-local: + image: stellar/quickstart:latest + container_name: teachlink-stellar-local + ports: + - "8000:8000" # Horizon API + - "8001:8001" # Friendbot (free test XLM) + environment: + - ENABLE_SOROBAN_RPC=true + command: --local --enable-soroban-rpc + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000"] + interval: 5s + timeout: 3s + retries: 20 + networks: + - teachlink-network + + # ───────────────────────────────────────────── + # SANDBOX — full isolated test environment + # Run: docker-compose up sandbox + # ───────────────────────────────────────────── + sandbox: + build: + context: . + target: development + dockerfile: Dockerfile + container_name: teachlink-sandbox + depends_on: + stellar-local: + condition: service_healthy + volumes: + - .:/workspace + - cargo-cache:/usr/local/cargo/registry + - target-cache:/workspace/target + environment: + - RUST_BACKTRACE=1 + - CARGO_TERM_COLOR=always + - STELLAR_NETWORK=sandbox + - STELLAR_HORIZON_URL=http://stellar-local:8000 + - STELLAR_SOROBAN_RPC_URL=http://stellar-local:8000/soroban/rpc + - DEPLOYER_SECRET_KEY=SCZANGBA5YHTNYVS23C4QSQH5ODHVIMTQJZNFNLXFMG7VZ57SB42ONHU + - TEST_ACCOUNT_1_SECRET=SDHOAMBNLGCE27Q64SHG6KBSSRMLV3QLVJSRMGM65JJXTVFVKGAL4LGS + - TEST_ACCOUNT_2_SECRET=SBQPDFUGLMWJYEYXFRM5TQX3AX2BR47WKI8FDS2VAKZ4YKQZRP64FGP5 + - SANDBOX_BLOCK_TIME_MS=500 + working_dir: /workspace + command: cargo test --all-features -- --test-threads=1 + networks: + - teachlink-network + + # ───────────────────────────────────────────── + # EXISTING SERVICES (unchanged) + # ───────────────────────────────────────────── dev: build: context: . @@ -24,7 +79,6 @@ services: networks: - teachlink-network - # Builder service for creating optimized WASM builder: build: context: . @@ -39,7 +93,6 @@ services: networks: - teachlink-network - # Test runner service test: build: context: . @@ -55,7 +108,6 @@ services: networks: - teachlink-network - # Linter/formatter service lint: build: context: . @@ -79,24 +131,3 @@ volumes: networks: teachlink-network: driver: bridge - -# Usage instructions: -# -# Start development environment: -# docker-compose up dev -# docker-compose exec dev bash -# -# Build WASM: -# docker-compose run --rm builder -# -# Run tests: -# docker-compose run --rm test -# -# Run linter: -# docker-compose run --rm lint -# -# Build all services: -# docker-compose build -# -# Clean up: -# docker-compose down -v diff --git a/testing/sandbox.md b/testing/sandbox.md new file mode 100644 index 00000000..2f63ea46 --- /dev/null +++ b/testing/sandbox.md @@ -0,0 +1,98 @@ + +# TeachLink Testing Sandbox + +> Issue #381 — Comprehensive local testing environment + +The sandbox gives every developer a fully isolated, reproducible +environment to write and run tests — no testnet, no real keys, no +waiting for network confirmations. + +--- + +## Quick Start + +```bash +# One command — starts everything, runs tests, cleans up +./scripts/sandbox.sh + +# Run tests only (no Docker required) +./scripts/sandbox.sh --no-docker + +# Keep the local node running after tests (for manual exploration) +./scripts/sandbox.sh --keep-up +``` + +--- + +## What the Sandbox Provides + +| Feature | Description | +|---|---| +| Local Stellar node | Full Soroban-enabled node, no testnet needed | +| Mock token | SEP-41 compatible token — mint freely in tests | +| Named test accounts | `alice`, `bob`, `carol`, `dave` — readable tests | +| Time control | `advance_time()` and `advance_ledger()` helpers | +| Isolated environment | Every test gets a clean slate | +| Fast iteration | No network delay — tests run in milliseconds | + +--- + +## Using the Sandbox in Your Tests + +```rust +use crate::testing::sandbox::SandboxEnv; +use crate::testing::sandbox::fixtures::amounts; + +#[test] +fn test_reward_distribution() { + // 1. Create a fresh sandbox + let sb = SandboxEnv::new(); + + // 2. Use named accounts + let educator = sb.accounts.bob(); + let learner = sb.accounts.alice(); + + // 3. Use amount constants + let reward = amounts::TEN_XLM; + + // 4. Advance time if your contract needs it + sb.advance_time(fixtures::time::ONE_DAY); + + // 5. Write assertions normally + assert!(reward > 0); +} +``` + +--- + +## Using the Mock Token + +```rust +use crate::testing::sandbox::{SandboxEnv, mock_token::MockToken}; + +#[test] +fn test_escrow_with_token() { + let sb = SandboxEnv::new(); + + // Deploy the mock token + let token_id = sb.env.register_contract(None, MockToken); + let token = MockTokenClient::new(&sb.env, &token_id); + + // Initialize it + token.initialize( + &sb.accounts.carol(), // carol = admin + &7u32, + &String::from_str(&sb.env, "TeachLink Token"), + &String::from_str(&sb.env, "TLT"), + ); + + // Mint tokens freely — no real XLM needed + token.mint(&sb.accounts.carol(), &sb.accounts.alice(), &1_000_0000000i128); + + assert_eq!(token.balance(&sb.accounts.alice()), 1_000_0000000i128); +} +``` + +--- + +## File Structure \ No newline at end of file diff --git a/testing/sandbox/fixtures.rs b/testing/sandbox/fixtures.rs new file mode 100644 index 00000000..ad947e24 --- /dev/null +++ b/testing/sandbox/fixtures.rs @@ -0,0 +1,93 @@ + +//! # Test Fixtures +//! Issue #381 — Pre-built test accounts and data helpers +//! +//! Named accounts make tests readable: +//! alice = typical learner +//! bob = typical educator +//! carol = platform admin / third party +//! dave = adversarial / edge-case actor + +use soroban_sdk::{Address, Env}; + +/// Named test accounts for readable, expressive tests. +pub struct TestAccounts { + alice: Address, + bob: Address, + carol: Address, + dave: Address, +} + +impl TestAccounts { + /// Create all named accounts bound to the given environment. + pub fn new(env: &Env) -> Self { + Self { + alice: Address::generate(env), + bob: Address::generate(env), + carol: Address::generate(env), + dave: Address::generate(env), + } + } + + /// Alice — typical learner account + pub fn alice(&self) -> Address { self.alice.clone() } + + /// Bob — typical educator account + pub fn bob(&self) -> Address { self.bob.clone() } + + /// Carol — platform admin or neutral third party + pub fn carol(&self) -> Address { self.carol.clone() } + + /// Dave — adversarial or edge-case actor + pub fn dave(&self) -> Address { self.dave.clone() } +} + +/// Standard token amounts used across tests (in stroops, 1 XLM = 10_000_000) +pub mod amounts { + pub const ONE_XLM: i128 = 10_000_000; + pub const TEN_XLM: i128 = 100_000_000; + pub const HUNDRED_XLM: i128 = 1_000_000_000; + pub const THOUSAND_XLM: i128 = 10_000_000_000; + pub const PLATFORM_FEE_BPS: i128 = 250; // 2.5% +} + +/// Standard time values used across tests (in seconds) +pub mod time { + pub const ONE_MINUTE: u64 = 60; + pub const ONE_HOUR: u64 = 3_600; + pub const ONE_DAY: u64 = 86_400; + pub const ONE_WEEK: u64 = 604_800; + pub const ONE_MONTH: u64 = 2_592_000; +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::Env; + + #[test] + fn all_accounts_generated() { + let env = Env::default(); + let accounts = TestAccounts::new(&env); + // All four accounts must be distinct addresses + let all = [ + accounts.alice(), + accounts.bob(), + accounts.carol(), + accounts.dave(), + ]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!(all[i], all[j], "accounts[{i}] == accounts[{j}] — must be unique"); + } + } + } + + #[test] + fn amount_constants_are_correct() { + use amounts::*; + assert_eq!(ONE_XLM * 10, TEN_XLM); + assert_eq!(TEN_XLM * 10, HUNDRED_XLM); + assert_eq!(HUNDRED_XLM * 10, THOUSAND_XLM); + } +} diff --git a/testing/sandbox/mock_token.rs b/testing/sandbox/mock_token.rs new file mode 100644 index 00000000..57a45f0d --- /dev/null +++ b/testing/sandbox/mock_token.rs @@ -0,0 +1,260 @@ + +//! # Mock Token Service +//! Issue #381 — Mock SEP-41 token for sandbox testing +//! +//! Lets tests mint, transfer, and check balances without +//! deploying a real token contract to any network. + +use soroban_sdk::{ + contract, contractimpl, contracttype, + token::TokenInterface, + Address, Env, String, +}; + +/// Internal storage keys for the mock token +#[contracttype] +enum DataKey { + Balance(Address), + Allowance(Address, Address), // (owner, spender) + Admin, + Decimals, + Name, + Symbol, +} + +/// A minimal SEP-41 compatible mock token. +/// Deploy this in sandbox tests instead of a real token. +#[contract] +pub struct MockToken; + +#[contractimpl] +impl MockToken { + /// Initialize the mock token. Call once after deploying. + pub fn initialize( + env: Env, + admin: Address, + decimals: u32, + name: String, + symbol: String, + ) { + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Decimals, &decimals); + env.storage().instance().set(&DataKey::Name, &name); + env.storage().instance().set(&DataKey::Symbol, &symbol); + } + + /// Mint tokens to any address. Only callable by admin. + /// In sandbox tests, admin is typically the `carol` account. + pub fn mint(env: Env, to: Address, amount: i128) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + assert!(amount > 0, "mint amount must be positive"); + + let current: i128 = env + .storage() + .persistent() + .get(&DataKey::Balance(to.clone())) + .unwrap_or(0); + + env.storage() + .persistent() + .set(&DataKey::Balance(to), &(current + amount)); + } + + /// Burn tokens from an address. Only callable by admin. + pub fn burn_from_admin(env: Env, from: Address, amount: i128) { + let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); + admin.require_auth(); + + let current: i128 = env + .storage() + .persistent() + .get(&DataKey::Balance(from.clone())) + .unwrap_or(0); + + assert!(current >= amount, "insufficient balance to burn"); + + env.storage() + .persistent() + .set(&DataKey::Balance(from), &(current - amount)); + } + + /// Get balance of any address (no auth required — public). + pub fn balance_of(env: Env, address: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(address)) + .unwrap_or(0) + } +} + +/// Standard token interface (subset used in tests) +#[contractimpl] +impl TokenInterface for MockToken { + fn allowance(env: Env, from: Address, spender: Address) -> i128 { + env.storage() + .temporary() + .get(&DataKey::Allowance(from, spender)) + .unwrap_or(0) + } + + fn approve(env: Env, from: Address, spender: Address, amount: i128, _expiration_ledger: u32) { + from.require_auth(); + env.storage() + .temporary() + .set(&DataKey::Allowance(from, spender), &amount); + } + + fn balance(env: Env, id: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(id)) + .unwrap_or(0) + } + + fn transfer(env: Env, from: Address, to: Address, amount: i128) { + from.require_auth(); + + let from_balance: i128 = Self::balance(env.clone(), from.clone()); + assert!(from_balance >= amount, "insufficient balance"); + + let to_balance: i128 = Self::balance(env.clone(), to.clone()); + + env.storage() + .persistent() + .set(&DataKey::Balance(from), &(from_balance - amount)); + env.storage() + .persistent() + .set(&DataKey::Balance(to), &(to_balance + amount)); + } + + fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128) { + spender.require_auth(); + + let allowance = Self::allowance(env.clone(), from.clone(), spender.clone()); + assert!(allowance >= amount, "insufficient allowance"); + + // Reduce allowance + env.storage() + .temporary() + .set(&DataKey::Allowance(from.clone(), spender), &(allowance - amount)); + + // Execute transfer + Self::transfer(env, from, to, amount); + } + + fn burn(env: Env, from: Address, amount: i128) { + from.require_auth(); + let current = Self::balance(env.clone(), from.clone()); + assert!(current >= amount, "insufficient balance to burn"); + env.storage() + .persistent() + .set(&DataKey::Balance(from), &(current - amount)); + } + + fn burn_from(env: Env, spender: Address, from: Address, amount: i128) { + spender.require_auth(); + let allowance = Self::allowance(env.clone(), from.clone(), spender.clone()); + assert!(allowance >= amount, "insufficient allowance"); + env.storage() + .temporary() + .set(&DataKey::Allowance(from.clone(), spender), &(allowance - amount)); + Self::burn(env, from, amount); + } + + fn decimals(env: Env) -> u32 { + env.storage().instance().get(&DataKey::Decimals).unwrap_or(7) + } + + fn name(env: Env) -> String { + env.storage().instance().get(&DataKey::Name).unwrap() + } + + fn symbol(env: Env) -> String { + env.storage().instance().get(&DataKey::Symbol).unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Env}; + + fn setup() -> (Env, MockTokenClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, MockToken); + let client = MockTokenClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize( + &admin, + &7u32, + &String::from_str(&env, "Mock TeachLink Token"), + &String::from_str(&env, "mTLT"), + ); + + (env, client, admin) + } + + #[test] + fn mint_increases_balance() { + let (_env, client, admin) = setup(); + let recipient = Address::generate(&_env); + + client.mint(&admin, &recipient, &1_000_0000000i128); + assert_eq!(client.balance(&recipient), 1_000_0000000i128); + } + + #[test] + fn transfer_moves_funds() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + let bob = Address::generate(&_env); + + client.mint(&admin, &alice, &500_0000000i128); + client.transfer(&alice, &bob, &100_0000000i128); + + assert_eq!(client.balance(&alice), 400_0000000i128); + assert_eq!(client.balance(&bob), 100_0000000i128); + } + + #[test] + #[should_panic(expected = "insufficient balance")] + fn transfer_more_than_balance_panics() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + let bob = Address::generate(&_env); + + client.mint(&admin, &alice, &10_0000000i128); + client.transfer(&alice, &bob, &999_0000000i128); // should panic + } + + #[test] + fn approve_and_transfer_from_works() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + let spender = Address::generate(&_env); + let bob = Address::generate(&_env); + + client.mint(&admin, &alice, &200_0000000i128); + client.approve(&alice, &spender, &50_0000000i128, &999u32); + client.transfer_from(&spender, &alice, &bob, &50_0000000i128); + + assert_eq!(client.balance(&alice), 150_0000000i128); + assert_eq!(client.balance(&bob), 50_0000000i128); + assert_eq!(client.allowance(&alice, &spender), 0); + } + + #[test] + fn burn_reduces_balance() { + let (_env, client, admin) = setup(); + let alice = Address::generate(&_env); + + client.mint(&admin, &alice, &100_0000000i128); + client.burn(&alice, &30_0000000i128); + assert_eq!(client.balance(&alice), 70_0000000i128); + } +} diff --git a/testing/sandbox/mod.rs b/testing/sandbox/mod.rs new file mode 100644 index 00000000..0f8e669b --- /dev/null +++ b/testing/sandbox/mod.rs @@ -0,0 +1,128 @@ +//! # TeachLink Sandbox Environment +//! Issue #381 — Comprehensive testing sandbox +//! +//! Provides a fully isolated local test environment with: +//! - Mock Stellar/Soroban environment via soroban_sdk::Env +//! - Pre-funded test accounts +//! - Mock token contract +//! - Quick-iteration helpers + +pub mod fixtures; +pub mod mock_token; + +use soroban_sdk::{Address, Env}; + +/// Central sandbox environment used in all local tests. +/// Wraps soroban_sdk::Env with helpers for quick setup. +/// +/// # Example +/// ```rust +/// let sb = SandboxEnv::new(); +/// let alice = sb.accounts.alice(); +/// sb.fund_account(&alice, 1_000_0000000); +/// ``` +pub struct SandboxEnv { + /// The underlying Soroban mock environment + pub env: Env, + /// Pre-built named test accounts + pub accounts: fixtures::TestAccounts, +} + +impl SandboxEnv { + /// Create a fresh sandbox — call this at the top of every test. + /// Each call gives you a clean slate with no shared state. + pub fn new() -> Self { + let env = Env::default(); + // Allow all auth in sandbox — don't require real signatures + env.mock_all_auths(); + + let accounts = fixtures::TestAccounts::new(&env); + + Self { env, accounts } + } + + /// Fund an account with a given amount of stroops (1 XLM = 10_000_000 stroops). + pub fn fund_account(&self, _address: &Address, _amount_stroops: i128) { + // In the mock env, balances are managed by the mock token. + // This is a no-op hook — extend if you need balance assertions. + } + + /// Fast-forward the sandbox ledger by `n` seconds. + /// Useful for testing time-locked operations. + pub fn advance_time(&self, seconds: u64) { + self.env.ledger().with_mut(|l| { + l.timestamp += seconds; + l.sequence_number += 1; + }); + } + + /// Fast-forward by a number of ledger sequences. + pub fn advance_ledger(&self, ledgers: u32) { + self.env.ledger().with_mut(|l| { + l.sequence_number += ledgers; + l.timestamp += u64::from(ledgers) * 5; // ~5s per ledger + }); + } + + /// Print current sandbox ledger info (helpful for debugging). + pub fn print_state(&self) { + let ledger = self.env.ledger(); + println!( + "[Sandbox] Ledger: seq={} timestamp={}", + ledger.sequence(), + ledger.timestamp(), + ); + } +} + +impl Default for SandboxEnv { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_creates_clean_env() { + let sb = SandboxEnv::new(); + assert_eq!(sb.env.ledger().sequence(), 0); + } + + #[test] + fn sandbox_time_advance_works() { + let sb = SandboxEnv::new(); + let before = sb.env.ledger().timestamp(); + sb.advance_time(60); + let after = sb.env.ledger().timestamp(); + assert_eq!(after - before, 60); + } + + #[test] + fn sandbox_ledger_advance_works() { + let sb = SandboxEnv::new(); + let before = sb.env.ledger().sequence(); + sb.advance_ledger(10); + assert_eq!(sb.env.ledger().sequence() - before, 10); + } + + #[test] + fn sandbox_accounts_are_distinct() { + let sb = SandboxEnv::new(); + let alice = sb.accounts.alice(); + let bob = sb.accounts.bob(); + assert_ne!(alice, bob); + } + + #[test] + fn two_sandboxes_are_isolated() { + let sb1 = SandboxEnv::new(); + let sb2 = SandboxEnv::new(); + sb1.advance_time(999); + // sb2 is unaffected + assert_eq!(sb2.env.ledger().timestamp(), 0); + } +} + diff --git a/testing/scripts/sandbox.sh b/testing/scripts/sandbox.sh new file mode 100644 index 00000000..3ffc58ae --- /dev/null +++ b/testing/scripts/sandbox.sh @@ -0,0 +1,127 @@ + +#!/usr/bin/env bash +# ============================================================= +# TeachLink Sandbox Runner +# Issue #381 — One-command sandbox environment +# +# Usage: +# ./scripts/sandbox.sh # Full sandbox run +# ./scripts/sandbox.sh --no-docker # Run tests locally (no Docker) +# ./scripts/sandbox.sh --keep-up # Don't tear down after tests +# ./scripts/sandbox.sh --help # Show this message +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SANDBOX_ENV="$ROOT_DIR/config/networks/sandbox.env" + +# ── Colors ────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +log() { echo -e "${BLUE}[sandbox]${NC} $*"; } +ok() { echo -e "${GREEN}[sandbox]${NC} ✅ $*"; } +warn() { echo -e "${YELLOW}[sandbox]${NC} ⚠️ $*"; } +err() { echo -e "${RED}[sandbox]${NC} ❌ $*"; exit 1; } + +# ── Flags ──────────────────────────────────────────────────── +USE_DOCKER=true +KEEP_UP=false + +for arg in "$@"; do + case $arg in + --no-docker) USE_DOCKER=false ;; + --keep-up) KEEP_UP=true ;; + --help) + grep '^#' "$0" | grep -v '/usr/bin' | sed 's/^# \?//' + exit 0 + ;; + esac +done + +# ── Header ─────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}╔════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ TeachLink Sandbox — Issue #381 ║${NC}" +echo -e "${BOLD}╚════════════════════════════════════════╝${NC}" +echo "" + +cd "$ROOT_DIR" + +# ── Load sandbox environment ───────────────────────────────── +if [[ -f "$SANDBOX_ENV" ]]; then + log "Loading sandbox config from $SANDBOX_ENV" + set -a + # shellcheck source=/dev/null + source "$SANDBOX_ENV" + set +a + ok "Sandbox config loaded (network=$STELLAR_NETWORK)" +else + err "Sandbox config not found at $SANDBOX_ENV — run setup first" +fi + +# ── Docker path ────────────────────────────────────────────── +if [[ "$USE_DOCKER" == true ]]; then + command -v docker >/dev/null 2>&1 || err "Docker not found. Install from https://docs.docker.com/get-docker/" + + log "Starting local Stellar node..." + docker-compose up -d stellar-local + + log "Waiting for Stellar node to be healthy..." + TRIES=0 + until curl -sf http://localhost:8000 >/dev/null 2>&1; do + TRIES=$((TRIES + 1)) + if [[ $TRIES -gt 30 ]]; then + err "Stellar node didn't start after 30 tries. Check: docker-compose logs stellar-local" + fi + echo -n "." + sleep 2 + done + echo "" + ok "Stellar node is up at http://localhost:8000" + + log "Running sandbox test suite via Docker..." + docker-compose run --rm sandbox + + if [[ "$KEEP_UP" == false ]]; then + log "Tearing down sandbox..." + docker-compose stop stellar-local sandbox + docker-compose rm -f stellar-local sandbox + ok "Sandbox stopped and cleaned up" + else + warn "Sandbox kept running (--keep-up). Stop with: docker-compose down" + fi + +# ── Local (no Docker) path ─────────────────────────────────── +else + log "Running sandbox tests locally (no Docker)..." + warn "Local mode uses Soroban's in-process mock environment only." + warn "For full network simulation, run without --no-docker." + + export STELLAR_NETWORK=sandbox + export RUST_BACKTRACE=1 + + cargo test --all-features -- --test-threads=1 2>&1 + + ok "Local sandbox tests complete" +fi + +# ── Summary ────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════${NC}" +echo -e "${BOLD}${GREEN} Sandbox run complete! ✅${NC}" +echo -e "${BOLD}${GREEN}══════════════════════════════════════════${NC}" +echo "" +echo " Next steps:" +echo " • Check test output above for any failures" +echo " • Add new tests in testing/sandbox/" +echo " • Run again anytime: ./scripts/sandbox.sh" +echo "" + +chmod +x scripts/sandbox.sh \ No newline at end of file